commit e6956aa00159fc769308f8a25a999e2d55c0543f Author: TaskPool Date: Sun Jul 26 08:43:52 2026 +0800 Initial commit: TaskPool React panel - React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..9666afa --- /dev/null +++ b/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "bin" + +[build] + args_bin = [] + entrypoint = "./bin/taskpool" + cmd = ":" + full_bin = "go run main.go server" + delay = 1000 + exclude_dir = ["internal/static/dist", "assets", "tmp", "vendor", "testdata", "web", "data", "envs", "configs", "bin"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eb4ef05 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# 排除构建产物 +dist/* linguist-generated=true + +# 排除第三方 +node_modules/* linguist-vendored=true + +# 排除文档 +docs/* linguist-documentation=true + +# SQL 统计为 SQL +*.sql linguist-language=SQL \ No newline at end of file diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..9509cdc --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,50 @@ +name: Build and Deploy + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: git.viaeon.com + IMAGE_NAME: admin/taskpool-react + +jobs: + build-and-push: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Build + working-directory: web + run: pnpm build + + - name: Login to Registry + run: echo "${{ secrets.TOKEN }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin + + - name: Build and push Docker image + run: | + docker build -f Dockerfile.standalone -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f3ccec5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: Bug 反馈 +description: 提交一个可复现的问题报告 +title: "[Bug] " +labels: + - bug +body: + - type: markdown + attributes: + value: | + 请尽量提供完整信息,便于我们复现问题并验证修复结果。 + 如果可以,请优先附上报错信息,或提供关键步骤截图。 + - type: textarea + id: description + attributes: + label: 问题描述 + description: 请说明当前出现了什么问题,以及你期望的行为是什么。 + placeholder: 请简要清晰地描述你遇到的 bug。 + validations: + required: true + - type: textarea + id: steps + attributes: + label: 复现步骤 + description: 请按顺序填写可以稳定复现问题的步骤,最好附上关键步骤截图。 + placeholder: | + 1. 进入 ... + 2. 点击 ... + 3. 看到 ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: 预期行为 + placeholder: 请描述你预期看到的结果。 + validations: + required: true + - type: textarea + id: actual + attributes: + label: 实际行为 + placeholder: 请描述实际结果,如有报错提示、异常信息或界面截图,请一并提供。 + validations: + required: true + - type: input + id: version + attributes: + label: 版本信息 + description: 请填写程序版本、commit hash 或镜像标签。 + placeholder: 例如:v1.2.3 + validations: + required: true + - type: dropdown + id: deployment + attributes: + label: 部署方式 + options: + - Docker + - Docker Compose + - 源码构建 + - 二进制发布版 + - 其他 + validations: + required: true + - type: input + id: environment + attributes: + label: 运行环境 + description: 请填写操作系统、浏览器、CPU 架构或其他相关环境信息。 + placeholder: 例如:Ubuntu 24.04、Chrome 134、amd64 + - type: textarea + id: logs + attributes: + label: 错误日志或截图 + description: 请粘贴错误日志、堆栈信息,或上传能帮助定位问题的截图。 + render: shell + - type: textarea + id: extra + attributes: + label: 补充信息 + description: 其他有助于理解或修复该问题的内容。 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a6f2656 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 使用文档 + url: https://github.com/engigu/taskpool/blob/main/README.md + about: 提交 issue 前请先查阅文档。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..16b1cff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +name: 需求与建议 +description: 提交新的功能需求、改进建议或特性请求 +title: "[Feature/Suggestion] " +labels: + - feature + - enhancement +body: + - type: markdown + attributes: + value: | + 感谢你为项目提供宝贵意见!请详细描述你的想法,以便我们评估和实现。 + - type: dropdown + id: type + attributes: + label: 类型 + options: + - 功能需求 (New Feature) + - 改进建议 (Enhancement/Suggestion) + - 其他优化 + validations: + required: true + - type: textarea + id: background + attributes: + label: 需求场景/背景 + description: 你的需求是为了解决什么样的问题?或者是在什么场景下产生的优化想法? + placeholder: 请简要说明背景,帮助我们理解痛点。 + validations: + required: true + - type: textarea + id: description + attributes: + label: 详细描述 + description: 请具体说明你心目中这个功能应该如何工作,或者改进的具体细节。 + placeholder: 请具体描述功能的运作逻辑或具体的改进细节。 + validations: + required: true + - type: textarea + id: benefit + attributes: + label: 预期收益 + description: 如果实现了该需求或建议,会带来哪些提升(如效率、视觉、体验等)? + - type: textarea + id: extra + attributes: + label: 补充信息 + description: 其他有助于理解该需求的内容,比如参考链接、概念图、截图等。 diff --git a/.github/workflows/build-base.yml b/.github/workflows/build-base.yml new file mode 100644 index 0000000..738fc69 --- /dev/null +++ b/.github/workflows/build-base.yml @@ -0,0 +1,184 @@ +name: Build-Base-Image + +on: + workflow_dispatch: # 手动触发 + schedule: + - cron: '0 3 * * 0' # 每周日凌晨3点自动构建一次 + push: + branches: + - main + paths: + - 'docker/Dockerfile.base' + - 'docker/Dockerfile.debian13.base' + - 'docker/Dockerfile.debian13.minimal' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: taskpool + +jobs: + build: + if: github.event.repository.fork == false + runs-on: ubuntu-latest + strategy: + matrix: + base_type: [debian, debian13, minimal] + platform: + - linux/amd64 + - linux/arm64 + include: + - base_type: debian + platform: linux/amd64 + dockerfile: docker/Dockerfile.base + tag_suffix: "" + - base_type: debian + platform: linux/arm64 + dockerfile: docker/Dockerfile.base + tag_suffix: "" +# - base_type: alpine +# platform: linux/amd64 +# dockerfile: docker/Dockerfile.alpine.base +# tag_suffix: "-alpine" +# - base_type: alpine +# platform: linux/arm64 +# dockerfile: docker/Dockerfile.alpine.base +# tag_suffix: "-alpine" + - base_type: debian13 + platform: linux/amd64 + dockerfile: docker/Dockerfile.debian13.base + tag_suffix: "-debian13" + - base_type: debian13 + platform: linux/arm64 + dockerfile: docker/Dockerfile.debian13.base + tag_suffix: "-debian13" + - base_type: minimal + platform: linux/amd64 + dockerfile: docker/Dockerfile.debian13.minimal + tag_suffix: "-minimal" + - base_type: minimal + platform: linux/arm64 + dockerfile: docker/Dockerfile.debian13.minimal + tag_suffix: "-minimal" + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set platform pair + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}-${{ matrix.base_type }}" >> $GITHUB_OUTPUT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=base${{ matrix.tag_suffix }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=base-${{ steps.platform.outputs.pair }} + cache-to: type=gha,scope=base-${{ steps.platform.outputs.pair }},mode=max + provenance: false + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v7 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: build + strategy: + matrix: + base_type: [debian, debian13, minimal] + include: + - base_type: debian + tag: base +# - base_type: alpine +# tag: base-alpine + - base_type: debian13 + tag: base-debian13 + - base_type: minimal + tag: base-minimal + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests + pattern: digests-*-${{ matrix.base_type }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ matrix.tag }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ matrix.tag }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..70d7f05 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,103 @@ +name: Build-Deploy-Server + +on: + push: + branches: [ main, dev ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: taskpool + +jobs: + build: + if: github.event.repository.fork == false + runs-on: ubuntu-latest + strategy: + matrix: + base_type: [debian] + include: + - base_type: debian + base_tag: base + tag_suffix: "" + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=raw,value=latest-amd64${{ matrix.tag_suffix }},enable={{is_default_branch}} + type=ref,event=branch,suffix=-amd64${{ matrix.tag_suffix }} + type=sha,prefix={{branch}}-,suffix=-amd64${{ matrix.tag_suffix }} + + - name: Get build time + id: build_time + run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ./docker/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + BUILD_TIME=${{ steps.build_time.outputs.time }} + BASE_TAG=${{ matrix.base_tag }} + cache-from: type=gha,scope=linux-amd64-${{ matrix.base_type }} + cache-to: type=gha,scope=linux-amd64-${{ matrix.base_type }},mode=max + provenance: false + + - name: Deploy to Demo server + if: github.ref == 'refs/heads/main' && matrix.base_type == 'debian' + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + password: ${{ secrets.DEPLOY_PASSWORD }} + port: ${{ secrets.DEPLOY_PORT }} + script: | + ${{ secrets.DEPLOY_SCRIPT }} + + + # - name: Deploy to X server + # if: github.ref == 'refs/heads/main' && matrix.base_type == 'debian' + # uses: appleboy/ssh-action@v1.2.0 + # with: + # host: ${{ secrets.DEPLOY_HOST }} + # username: ${{ secrets.DEPLOY_USER }} + # password: ${{ secrets.DEPLOY_PASSWORD }} + # port: ${{ secrets.DEPLOY_PORT }} + # script: | + # cd ${{ secrets.DEPLOY_X_PATH }} + # docker-compose -f docker-compose.yml pull + # docker-compose -f docker-compose.yml up -d \ No newline at end of file diff --git a/.github/workflows/docker-amd64.yml b/.github/workflows/docker-amd64.yml new file mode 100644 index 0000000..9772d61 --- /dev/null +++ b/.github/workflows/docker-amd64.yml @@ -0,0 +1,80 @@ +name: Build-Docker-AMD64 + +on: + # push: + # tags: [ 'v*' ] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: taskpool + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + base_type: [debian, debian13, minimal] + include: + - base_type: debian + base_tag: base + tag_suffix: "" + # - base_type: alpine + # base_tag: base-alpine + # tag_suffix: "-alpine" + - base_type: debian13 + base_tag: base-debian13 + tag_suffix: "-debian13" + - base_type: minimal + base_tag: base-minimal + tag_suffix: "-minimal" + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=ref,event=branch,suffix=-amd64${{ matrix.tag_suffix }} + + - name: Get build time + id: build_time + run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.base_type == 'minimal' && './docker/Dockerfile.minimal' || './docker/Dockerfile' }} + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + BUILD_TIME=${{ steps.build_time.outputs.time }} + BASE_TAG=${{ matrix.base_tag }} + cache-from: type=gha,scope=linux-amd64-${{ matrix.base_type }} + cache-to: type=gha,scope=linux-amd64-${{ matrix.base_type }},mode=max + provenance: false diff --git a/.github/workflows/docker-arm64.yml b/.github/workflows/docker-arm64.yml new file mode 100644 index 0000000..ec73705 --- /dev/null +++ b/.github/workflows/docker-arm64.yml @@ -0,0 +1,89 @@ +name: Build-Docker-ARM64 + +# on: +# # push: +# # tags: [ 'v*' ] +# workflow_dispatch: + +on: + push: + branches: [ main, dev ] + workflow_dispatch: + + +env: + REGISTRY: ghcr.io + IMAGE_NAME: taskpool + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + base_type: [debian, debian13, minimal] + include: + - base_type: debian + base_tag: base + tag_suffix: "" + # - base_type: alpine + # base_tag: base-alpine + # tag_suffix: "-alpine" + - base_type: debian13 + base_tag: base-debian13 + tag_suffix: "-debian13" + - base_type: minimal + base_tag: base-minimal + tag_suffix: "-minimal" + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=ref,event=branch,suffix=-arm64${{ matrix.tag_suffix }} + + - name: Get build time + id: build_time + run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.base_type == 'minimal' && './docker/Dockerfile.minimal' || './docker/Dockerfile' }} + platforms: linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ github.ref_name }} + BUILD_TIME=${{ steps.build_time.outputs.time }} + BASE_TAG=${{ matrix.base_tag }} + cache-from: type=gha,scope=linux-arm64-${{ matrix.base_type }} + cache-to: type=gha,scope=linux-arm64-${{ matrix.base_type }},mode=max + provenance: false diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..bcea45b --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,74 @@ +name: Deploy VitePress to Pages + +on: + push: + branches: [main, docs] + workflow_dispatch: + schedule: + - cron: '0 1 * * *' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + if: github.event.repository.fork == false + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: docs/package-lock.json + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + cache: true + + - name: Generate Swagger Docs + run: make swag + + - name: Setup Pages + uses: actions/configure-pages@v6 + + - name: Install dependencies + run: npm ci + working-directory: docs + + - name: Fetch Package Download Stats + run: node docs/fetch-stats.js + + - name: Build with VitePress + run: npm run docs:build + working-directory: docs + + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + name: Deploy + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..53d9a6b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,285 @@ +name: Release-and-Build-Docker-Image + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: taskpool + +jobs: + build-binaries: + runs-on: macos-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Build Web + run: make build-web + + - name: Generate Swagger + run: make swag + + - name: Build Binaries + run: | + VERSION=${{ github.ref_name }} + BUILD_TIME=$(TZ='Asia/Shanghai' date '+%Y/%m/%d %H:%M:%S') + LDFLAGS="-s -w -X 'github.com/engigu/taskpool/internal/constant.Version=$VERSION' -X 'github.com/engigu/taskpool/internal/constant.BuildTime=$BUILD_TIME'" + + # Prepare static assets for embedding + rm -rf internal/static/dist + cp -r web/dist internal/static/dist + + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags web -ldflags="$LDFLAGS" -o taskpool-linux-amd64 main.go + tar -czvf taskpool-linux-amd64.tar.gz taskpool-linux-amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags web -ldflags="$LDFLAGS" -o taskpool-linux-arm64 main.go + tar -czvf taskpool-linux-arm64.tar.gz taskpool-linux-arm64 + CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -tags web -ldflags="$LDFLAGS" -o taskpool-android-arm64 main.go + tar -czvf taskpool-android-arm64.tar.gz taskpool-android-arm64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -tags web -ldflags="$LDFLAGS" -o taskpool-linux-armv7 main.go + tar -czvf taskpool-linux-armv7.tar.gz taskpool-linux-armv7 + + - name: Build Agent Binaries + run: | + VERSION=${{ github.ref_name }} + BUILD_TIME=$(TZ='Asia/Shanghai' date '+%Y/%m/%d %H:%M:%S') + AGENT_LDFLAGS="-s -w -X 'main.Version=$VERSION' -X 'main.BuildTime=$BUILD_TIME'" + mkdir -p data/agent + echo "$VERSION" > data/agent/version.txt + + # Linux AMD64 + cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-linux-amd64 . && cd .. + cd data/agent && cp ../../agent/config.example.ini . && tar -czvf taskpool-agent-linux-amd64.tar.gz taskpool-agent-linux-amd64 config.example.ini && rm taskpool-agent-linux-amd64 && cd ../.. + + # Linux ARM64 + cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-linux-arm64 . && cd .. + cd data/agent && tar -czvf taskpool-agent-linux-arm64.tar.gz taskpool-agent-linux-arm64 config.example.ini && rm taskpool-agent-linux-arm64 && cd ../.. + + # Windows AMD64 + cd agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-windows-amd64.exe . && cd .. + cd data/agent && zip taskpool-agent-windows-amd64.zip taskpool-agent-windows-amd64.exe config.example.ini && rm taskpool-agent-windows-amd64.exe && cd ../.. + + # Darwin AMD64 + cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-darwin-amd64 . && cd .. + cd data/agent && tar -czvf taskpool-agent-darwin-amd64.tar.gz taskpool-agent-darwin-amd64 config.example.ini && rm taskpool-agent-darwin-amd64 && cd ../.. + + # Darwin ARM64 + cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-darwin-arm64 . && cd .. + cd data/agent && tar -czvf taskpool-agent-darwin-arm64.tar.gz taskpool-agent-darwin-arm64 config.example.ini && rm taskpool-agent-darwin-arm64 && cd ../.. + + # Android ARM64 + cd agent && CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags="$AGENT_LDFLAGS" -o ../data/agent/taskpool-agent-android-arm64 . && cd .. + cd data/agent && tar -czvf taskpool-agent-android-arm64.tar.gz taskpool-agent-android-arm64 config.example.ini && rm taskpool-agent-android-arm64 && cd ../.. + - name: Prepare Release Notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view ${{ github.ref_name }} > /dev/null 2>&1; then + echo "$(cat CHANGELOG.md)" > FINAL_CHANGELOG.md + echo -e "\n\n" >> FINAL_CHANGELOG.md + gh release view ${{ github.ref_name }} --json body -q .body >> FINAL_CHANGELOG.md + else + cp CHANGELOG.md FINAL_CHANGELOG.md + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + body_path: FINAL_CHANGELOG.md + files: | + taskpool-linux-amd64.tar.gz + taskpool-linux-arm64.tar.gz + taskpool-linux-armv7.tar.gz + taskpool-android-arm64.tar.gz + data/agent/taskpool-agent-*.tar.gz + data/agent/taskpool-agent-*.zip + + build: + runs-on: ubuntu-latest + strategy: + matrix: + base_type: [debian, debian13, minimal] + platform: + - linux/amd64 + - linux/arm64 + include: + - base_type: debian + platform: linux/amd64 + base_tag: base + tag_suffix: "" + - base_type: debian + platform: linux/arm64 + base_tag: base + tag_suffix: "" +# - base_type: alpine +# platform: linux/amd64 +# base_tag: base-alpine +# tag_suffix: "-alpine" +# - base_type: alpine +# platform: linux/arm64 +# base_tag: base-alpine +# tag_suffix: "-alpine" + - base_type: debian13 + platform: linux/amd64 + base_tag: base-debian13 + tag_suffix: "-debian13" + - base_type: debian13 + platform: linux/arm64 + base_tag: base-debian13 + tag_suffix: "-debian13" + - base_type: minimal + platform: linux/amd64 + base_tag: base-minimal + tag_suffix: "-minimal" + - base_type: minimal + platform: linux/arm64 + base_tag: base-minimal + tag_suffix: "-minimal" + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set platform pair + id: platform + run: | + platform=${{ matrix.platform }} + echo "pair=${platform//\//-}-${{ matrix.base_type }}" >> $GITHUB_OUTPUT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + + - name: Get build time + id: build_time + run: echo "time=$(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.base_type == 'minimal' && './docker/Dockerfile.minimal' || './docker/Dockerfile' }} + platforms: ${{ matrix.platform }} + build-args: | + VERSION=${{ github.ref_name }} + BUILD_TIME=${{ steps.build_time.outputs.time }} + BASE_TAG=${{ matrix.base_tag }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ steps.platform.outputs.pair }} + cache-to: type=gha,scope=${{ steps.platform.outputs.pair }},mode=max + provenance: false + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v7 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: [build, build-binaries] + strategy: + matrix: + base_type: [debian, debian13, minimal] + include: + - base_type: debian + tag_suffix: "" +# - base_type: alpine +# tag_suffix: "-alpine" + - base_type: debian13 + tag_suffix: "-debian13" + - base_type: minimal + tag_suffix: "-minimal" + + permissions: + contents: write + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests + pattern: digests-*-${{ matrix.base_type }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=semver,pattern={{version}}${{ matrix.tag_suffix }} + type=raw,value=latest${{ matrix.tag_suffix }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0daafb --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Binaries +taskpool +*.exe +*.dll +*.so +*.dylib + +# Go +# go.sum should be committed for reproducible builds + +# Data & Logs +data/ +agent/logs/ +!data/agent/ +data/agent/* +!data/agent/version.txt +envs/ +.kiro/ +# logs/ +# scripts/ +configs/config.ini +agent/config.ini +agent/agent.pid +agent/taskpool-agent +web/dist/ +web/dev-dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Node +node_modules/ + +# Embedded static files (built during CI/Docker) +internal/static/dist/ + +# Config (sensitive) +configs/config.local.json + +# Python +__pycache__/ + +# Misc +.stfolder + +bin/ + +*.log + +# VitePress +docs/.vitepress/dist/ +docs/.vitepress/cache/ +docs/node_modules/ +docs/public/swagger.json +docs/public/swagger.yaml + +# OpenAPI +openapi_docs/ + +# Builtin SDK (Force Include) +!builtin/ +!builtin/** +builtin/**/__pycache__/ +builtin/**/*.pyc + +.agents/ + + +# Local secrets / runtime +cookies.txt +debug.log +backend-dev.log +.ref-baihu/ +scripts/e2e-check.mjs.bak diff --git a/.stignore b/.stignore new file mode 100644 index 0000000..3113b42 --- /dev/null +++ b/.stignore @@ -0,0 +1,4 @@ +.git/ +web/node_modules +web/dist +envs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9ca8629 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# 更新日志 (v1.1.20) + +### 2026.07.13 - 日志 ZSTD 压缩、依赖补全与计划任务排序 + +🎉 **新增与优化** +* **日志 ZSTD 压缩升级**:日志流式压缩机制由 zlib 全面升级至 ZSTD,显著降低磁盘开销与带宽占用;前端集成 `fzstd` 无缝支持新格式解码,并实现了对旧版 zlib 的向后兼容;针对小于 128 字节的短日志自动绕过压缩,避免资源浪费。 +* **依赖自动补全与交互终端 (#147)**:全新上线依赖分析与自动补全安装 CLI,并提供终端安装引导;在定时任务日志页支持通过“补全依赖”一键调出内嵌终端进行交互式依赖补全。 +* **计划任务表头排序 (#148)**:大屏与中屏布局支持点击“名称”、“执行时间”(下次执行时间)和“状态”表头进行排序,后端支持 `sort_by` 与 `order` 传参并保证置顶任务最高优先级;移动端顶栏同步新增了“排序规则”下拉选择器。 +* **过滤视图联动**:自定义过滤视图功能全面支持排序参数(`sort_by` / `order`)联动保存,应用视图时自动还原当时的排序配置。 +* **全局 ESC 关闭弹窗**:底层通用 Dialog 组件集成了非侵入式全局 Escape 按键拦截机制,优先退出最顶层弹窗,避免输入框/Monaco等组件焦点被抢占时 ESC 失效。 + +**✨ 修复与改进** +* **样式与体验**:将大屏及中屏下的状态列宽度由 `w-8` 扩大至 `w-14`,彻底消除因加入排序图标导致的文字折行与表头挤压;在 DialogContent 上追加了聚焦样式清除,消除了窗口边缘的白色聚焦边框;为新建任务的日志清理配置默认设置为保留最近 30 条记录,防爆盘;日志详情弹窗增加了最大高度及滚动条优化。 + +--- + + + +--- + + +> 出于安全及环境隔离考虑,推荐使用 Docker/Compose 部署方式。[镜像地址](https://github.com/engigu/taskpool/pkgs/container/taskpool) + + + +### 🐳 方式一:Docker 部署(推荐) +[部署文档](https://github.com/engigu/taskpool?tab=readme-ov-file#%E5%BF%AB%E9%80%9F%E9%83%A8%E7%BD%B2) + +### 🚀 方式二:单文件部署 +从当前 Release 的附件中下载对应架构的部署压缩包(如 `taskpool-linux-amd64.tar.gz`),然后使用以下命令提取并运行: + +**⚠️ 重要前置依赖:手动安装 `mise`** +单文件直接运行依赖宿主机系统环境,请务必先安装 [mise](https://mise.jdx.dev/getting-started.html) 供任务调度及环境管理使用: +```bash +curl https://mise.run | sh +export PATH="~/.local/share/mise/bin:~/.local/share/mise/shims:$PATH" +``` + +**运行面板:** +```bash +tar -xzvf taskpool-linux-amd64.tar.gz +chmod +x taskpool-linux-amd64 +./taskpool-linux-amd64 server +``` + +--- + +**访问面板:** +启动后访问:http://localhost:8052 + +**登录信息:** +默认账号:用户名 `admin`,密码见面板首次启动时的控制台日志。 + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f191ea6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# Production stage - only needs nginx and built files +FROM nginx:alpine + +# Copy built files (from CI artifact or local build) +COPY web/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Dockerfile.standalone b/Dockerfile.standalone new file mode 100644 index 0000000..3349d22 --- /dev/null +++ b/Dockerfile.standalone @@ -0,0 +1,40 @@ +# Build frontend +FROM node:22-alpine AS frontend-builder +WORKDIR /app/web +COPY web/package.json web/pnpm-lock.yaml ./ +RUN npm install -g pnpm && pnpm install --frozen-lockfile +COPY web/ . +RUN pnpm build + +# Build backend +FROM golang:1.24-alpine AS backend-builder +WORKDIR /app +RUN apk add --no-cache git +ENV GOTOOLCHAIN=auto +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /taskpool . + +# Production stage +FROM alpine:3.19 +RUN apk add --no-cache nginx supervisor + +# Copy frontend +COPY --from=frontend-builder /app/web/dist /usr/share/nginx/html + +# Copy backend +COPY --from=backend-builder /taskpool /app/taskpool + +# Copy nginx config (modified for localhost) +COPY nginx-standalone.conf /etc/nginx/http.d/default.conf + +# Copy supervisor config +COPY supervisord.conf /etc/supervisord.conf + +# Create data directories +RUN mkdir -p /app/data /app/configs /app/envs /app/logs + +EXPOSE 3000 8052 + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6ba6faf --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 2025 engigu + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..634fd00 --- /dev/null +++ b/Makefile @@ -0,0 +1,230 @@ +# Variables +BINARY=bin/taskpool +GOBUILD=go build +GOCLEAN=go clean +GOGET=go get +GOMOD=go mod +VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +BUILD_TIME=$(shell date '+%Y/%m/%d %H:%M:%S') +LDFLAGS=-ldflags="-s -w -X 'github.com/engigu/taskpool/internal/constant.Version=$(VERSION)' -X 'github.com/engigu/taskpool/internal/constant.BuildTime=$(BUILD_TIME)'" + +TAGS_WEB=-tags web + +DEV_UID ?= $(shell id -u 2>/dev/null || echo 1000) +DEV_GID ?= $(shell id -g 2>/dev/null || echo 1000) +export DEV_UID +export DEV_GID + +# Default target +all: build + +# Build frontend +build-web: + cd web && npm ci && npm run build + +pack-webui: + @echo "==> [1/6] 验证参数有效性..." + @if [ -z "$(NAME)" ] || [ -z "$(VERSION)" ] || [ -z "$(AUTHOR)" ] || [ -z "$(DESC)" ]; then \ + echo "Error: Missing required arguments!"; \ + echo "Usage: make pack-webui NAME= VERSION= AUTHOR= DESC="; \ + exit 1; \ + fi + @if [ "$(NAME)" = "default" ]; then \ + echo "Error: WebUI name cannot be 'default' ('default' is reserved for the built-in system identifier)."; \ + exit 1; \ + fi + @echo "==> [2/6] 正在安装前端依赖包 (npm i)..." + cd web && npm i + @echo "==> [3/6] 正在编译构建前端资源文件 (npm run build)..." + cd web && npm run build + @echo "==> [4/6] 正在准备归档输出目录与清理旧包..." + @mkdir -p bin + @rm -f bin/webui-$(NAME)-$(VERSION).tar.gz + @echo "==> [5/6] 正在生成包配置文件 uimanifest.json..." + @echo '{"name": "$(NAME)", "version": "$(VERSION)", "author": "$(AUTHOR)", "description": "$(DESC)"}' > web/dist/uimanifest.json + @echo "==> [6/6] 正在压缩打包为 tar.gz 归档包..." + @sleep 2 + cd web/dist && tar -czf ../../bin/webui-$(NAME)-$(VERSION).tar.gz * + @echo "==> 打包成功!资源包已创建于: bin/webui-$(NAME)-$(VERSION).tar.gz" + +# Build the application (requires frontend to be built first) +build: + @mkdir -p bin + CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) -o $(BINARY) main.go + +# Build release version (Frontend + Backend with embedded assets) +release: + cd web && npm ci && npm run build + @mkdir -p bin + rm -rf internal/static/dist + cp -r web/dist internal/static/dist + CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) $(TAGS_WEB) -o $(BINARY) main.go + +# Build release version for Android Termux +release-android: + cd web && npm ci && npm run build + @mkdir -p bin + rm -rf internal/static/dist + cp -r web/dist internal/static/dist + CGO_ENABLED=0 GOOS=android GOARCH=arm64 $(GOBUILD) -trimpath $(LDFLAGS) $(TAGS_WEB) -o bin/taskpool-android-arm64 main.go + +# Build release version (Frontend + Backend with embedded assets) +release-binary: + cd web && npm ci && VITE_RELEASE_OPTIMIZE=true npm run build + @mkdir -p bin + rm -rf internal/static/dist + cp -r web/dist internal/static/dist + CGO_ENABLED=0 $(GOBUILD) $(LDFLAGS) $(TAGS_WEB) -o $(BINARY) main.go + +# Alias for backward compatibility +build-all: release + +# Build agent for all platforms +build-agent: build-agent-linux-amd64 build-agent-linux-arm64 build-agent-android-arm64 build-agent-windows-amd64 build-agent-darwin-amd64 build-agent-darwin-arm64 + @echo "All agent packages built in data/agent/" + @ls -lh data/agent/taskpool-agent-* + +AGENT_LDFLAGS=-s -w -X 'main.Version=$(VERSION)' -X 'main.BuildTime=$(BUILD_TIME)' + +build-agent-linux-amd64: + @mkdir -p data/agent + @echo "$(VERSION)" > data/agent/version.txt + cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-linux-amd64 . + +build-agent-linux-arm64: + @mkdir -p data/agent + @echo "$(VERSION)" > data/agent/version.txt + cd agent && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-linux-arm64 . + +build-agent-android-arm64: + @mkdir -p data/agent + @echo "$(VERSION)" > data/agent/version.txt + cd agent && CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-android-arm64 . + +build-agent-windows-amd64: + @mkdir -p data/agent + @echo "$(VERSION)" > data/agent/version.txt + cd agent && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-windows-amd64.exe . + +build-agent-darwin-amd64: + @mkdir -p data/agent + @echo "$(VERSION)" > data/agent/version.txt + cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-darwin-amd64 . + +build-agent-darwin-arm64: + @mkdir -p data/agent + @echo "$(VERSION)" > data/agent/version.txt + cd agent && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(AGENT_LDFLAGS)" -o ../data/agent/taskpool-agent-darwin-arm64 . + +# Clean built files +clean: + $(GOCLEAN) + rm -rf bin/ + rm -rf internal/static/dist + rm -rf web/dist + +# Clean everything: local artifacts and Docker development environment (including volumes) +clean-all: clean docker-dev-clean + rm -rf web/node_modules + @echo "All local artifacts and Docker dev caches have been completely wiped." + +# Run the application +run: + @mkdir -p bin + $(GOBUILD) -o $(BINARY) main.go + ./$(BINARY) server + +# Development run with hot reload (both frontend and backend) +dev: + @command -v concurrently > /dev/null 2>&1 || npm install -g concurrently + @mkdir -p envs web/node_modules + concurrently --kill-others \ + "go tool air" \ + "cd web && npm ci && npm run dev" + +# Run agent with hot reload +agent-dev: + go tool air -c agent.air.toml + +# Run agent +agent-run: + @mkdir -p bin + $(GOBUILD) -o bin/taskpool-agent ./agent + ./bin/taskpool-agent run -c ../agent/config.ini + +# Install dependencies +deps: + $(GOMOD) tidy + +# Generate swagger documentation +swag: + @mkdir -p docs/public + go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o ./docs/public --ot json,yaml + +docs-dev: + cd docs && npm run docs:dev + +docs-build: + cd docs && npm run docs:build + +# Docker build +docker-build: + docker build -t taskpool:dev -f docker/Dockerfile . + +# Docker run +docker-run: + docker run -p 8052:8052 taskpool:dev + +# Docker compose up +docker-up: + docker compose up -d + +# Docker compose down +docker-down: + docker compose down + +# Start isolated Docker dev environment (foreground with logs, Ctrl+C to stop) +docker-dev: + @command -v concurrently > /dev/null 2>&1 || npm install -g concurrently + @mkdir -p envs web/node_modules + docker compose -f docker-compose.dev.yml up --build + +# Start isolated Docker dev environment (background) +docker-dev-d: + docker compose -f docker-compose.dev.yml up -d --build + +# Stop Docker dev environment (preserves cached volumes for fast restart) +docker-dev-down: + docker compose -f docker-compose.dev.yml down + +# Stop and completely clean Docker dev environment (removes all cached volumes) +# Use this if your environment is broken or you want a fresh start +docker-dev-clean: + docker compose -f docker-compose.dev.yml down -v + +# Help +help: + @echo "Available targets:" + @echo " all - Build backend only (default)" + @echo " build - Build backend binary (no UI embedded)" + @echo " release - Build full release binary (with UI embedded)" + @echo " build-web - Build frontend assets only" + @echo " pack-webui - Build and package custom WebUI tar.gz" + @echo " build-agent - Build agent packages (tar.gz) for all platforms" + @echo " clean - Clean built files" + @echo " clean-all - Clean local files and Docker dev environment (including volumes)" + @echo " run - Run the application locally" + @echo " dev - Run local development with hot reload" + @echo " deps - Install Go dependencies" + @echo " docker-build - Build production Docker image" + @echo " docker-run - Run production Docker container" + @echo " docker-up - Start production Docker Compose stack" + @echo " docker-down - Stop production Docker Compose stack" + @echo " docker-dev - Start isolated Docker dev environment (foreground)" + @echo " docker-dev-d - Start isolated Docker dev environment (background)" + @echo " docker-dev-down - Stop Docker dev environment (keep caches)" + @echo " docker-dev-clean - Stop and clean Docker dev environment (remove caches)" + @echo " swag - Generate swagger documentation and sync with docs" + @echo " docs-dev - Run documentation development server" + @echo " docs-build - Build documentation" + @echo " help - Show this help message" diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c500a66 --- /dev/null +++ b/NOTICE @@ -0,0 +1,21 @@ +TaskPool (任务池) +Copyright 2025 engigu + +This product includes software developed by engigu. + +========================================================================= +ATTRIBUTION NOTICE +========================================================================= + +According to the Apache License, Version 2.0 (the "License"), any +redistribution of this Work or Derivative Works thereof must retain +this NOTICE file and the attribution contained within. + +YOU MUST NOT REMOVE THE ORIGINAL AUTHOR'S NAME ("engigu"), COPYRIGHT +NOTICES, OR THE PROJECT NAME ("TaskPool" / "任务池") FROM ANY +PART OF THE SOURCE CODE, UI, OR DOCUMENTATION. + +Any derivative work or modified version must include a prominent and +verifiable attribution to the original author. + +========================================================================= diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3d378c --- /dev/null +++ b/README.md @@ -0,0 +1,778 @@ +# 任务池 + +[![Hits](https://hits.sh/github.com/engigu/taskpool.svg?view=today-total)](https://hits.sh/github.com/engigu/taskpool/) +![Version](https://img.shields.io/github/v/tag/engigu/taskpool?color=44cc11&label=docker%20version&sort=semver) +![Image Size](https://ghcr-badge.egpl.dev/engigu/taskpool/size?color=%2344cc11&tag=latest&label=docker+image&trim=) +![Image pulls](https://img.shields.io/badge/dynamic/json?url=https://ghcr-badge.elias.eu.org/api/engigu/taskpool/taskpool&query=downloadCount&style=flat&label=docker%20pulls&color=44cc11) + + +任务池 (TaskPool) 是一款极致轻量、高性能的自动化任务调度平台。采用 Go + Vue3 架构,专注于高性能与低系统开销。通过深度集成 Mise 运行时管理,它原生支持 Python、Node.js、Go、Rust、PHP 等所有主流语言环境的动态安装(几乎所有的版本)与统一依赖管理。支持 Docker/Docker-Compose 一键部署,开箱即用,是您理想的轻量化脚本托管与任务调度解决方案。 + +演示站点(演示站点的服务器比较烂,见谅) [演示站点](https://taskpool-demo-site.qwapi.eu.org/) + +文档说明 [文档说明](https://engigu.github.io/taskpool/) + +## 更新日志 ☕ + +### 最近更新 +**2026.07.13** - **ZSTD 日志与任务排序**:流式日志升级至 ZSTD 压缩,支持 Zlib 兼容;上线依赖自动补全交互式终端;支持任务列表及移动端、视图联动排序;全局 ESC 非侵入式退出弹窗。 +**2026.06.28** - **节点互联与终端 UX**:全面升级节点互联架构(支持变量同步、OpenConnect 协议及同步大屏);优化移动端终端交互,引入 Worker 边界防护防 OOM。 +**2026.06.12** - **调度器与资源实时监控**:新增对面板运行资源、并发调度池(Worker)及内存堆栈的实时高频监控指标展示。 +**2026.05.29** - **自定义前端 (WebUI)**:新增 WebUI 插件化管理机制,支持上传管理第三方前端包 (`.tar.gz`) 接管内置界面,支持深度主题化定制。详情请看[前端定制说明文档](docs/guide/webui.md) +**2026.04.16** - **内建脚本助手库 (Built-in SDK)**:新增 Python 与 Node.js 的轻量化助手库,实现脚本内 “零代码配置” 通知投递;配套新增 `taskpool builtininstall` 自动化安装命令。 +**2026.04.14** - **PWA 与通知渠道增强**:支持 PWA (Progressive Web App) 动态配置,站点标题与图标可由后端实时控制;新增 **VoceChat** 通知渠道支持;增强 **Bark** 推送,支持自建服务器配置。 +**2026.03.27** - **安全机密管理 (GitHub Secrets 风格)**:新增系统级机密(Secret)管理功能。支持 AES-GCM 工业级加密存储,秘钥内存留存销毁;支持执行日志自动脱敏打码;支持仅在计划任务调度时按需注入,终端与测试运行物理隔离,全面提升敏感配置安全性。 +**2026.03.19** - **仓库同步增强**:新增对青龙仓库格式指令的深度兼容,支持从远程 Git 仓库自动同步脚本并基于注释解析自动创建面板任务,支持白名单、黑名单、依赖保留等高级筛选特性。 +**2026.03.05** - **API 文档重构** 重构 OpenAPI 认证体系,支持站点级 Token 配置与 Basic Auth 保护。 +**2026.03.04** - 新增内置消息推送系统:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道的推送,接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史 +**2026.02.13** - 重构任务执行引擎:深度集成 Mise 运行时管理,支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换,同步上线跨语言统一依赖管理系统 +**2026.02.11** - 增强安全性:首次启动使用随机密码并打印在日志中,登录接口增加防暴力破解,文件系统操作增加路径穿越锁定 +**2026.02.10** - 重构任务调度系统,完善并发控制,优化文件树交互体验,支持任务执行实时日志流 +**2026.02.06** - 整理 Docker 目录结构,增加 Debian 13 (Trixie) 镜像支持 + +[查看完整更新日志](./CHANGELOG.md) + +## 项目来由 + +多少和青龙面板有点关系,我自己也是青龙面板的使用者,但是现在的青龙面板性能我觉得有点难以接受。以我自己的使用(`机器1C2G`)为例,一个`python`的`requests`脚本每隔`30s`执行一次,有时候cpu执行的时候能跳变到`50%`以上。可以看看下面gif图片(如果不动,点击图片查看) + +![qinglong.gif](https://f.pz.al/pzal/2025/12/24/2d245b0a77f26.gif) + +我觉得一个内存和性能占用低的面板更合适自己,所以做了这个项目。 + +如果你和我一样需要一个性能和内存占用低的定时面板,这个项目你可以体验下。 + +同样的定时场景和代码,这个项目的情况如下(cpu执行定时跳变不超过`20%`): + +![taskpool.gif](https://f.pz.al/pzal/2025/12/24/f0d171f9a686d.gif) + +如果项目有用,请帮忙点个star。 + +## 特色 + +- **轻量级:** docker/compose部署,无需复杂配置,开箱即用 +- **任务调度:** 支持标准 Cron 表达式,常用时间规则快捷选择。日志不落文件,没有磁盘频繁io的问题 +- **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压 +- **在线终端:** WebSocket 实时终端,命令执行结果实时输出 +- **消息推送:** 内置强大消息推送与通知引擎,无缝兼容主流渠道,支持系统级事件告警 +- **机密管理:** **(New)** 类似 GitHub Secrets 的安全存储,支持 AES-GCM 加密,日志自动打码,仅在调度时注入 +- **环境变量:** 存储普通配置,任务执行时自动注入 +- **现代UI:** 响应式设计,深色/浅色主题切换 +- **移动端:** 适配移动小屏样式 +- **远程执行:** 支持远程agent执行任务,展示执行结果 +- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理 +- **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL + +## 功能特性 + +
+点击展开查看详细功能 + +### 定时任务管理 +- 支持标准 Cron 表达式调度 +- 常用时间规则快捷选择 +- 任务启用/禁用状态切换 +- 手动触发执行 +- 任务超时控制 + +### 脚本文件管理 +- 在线代码编辑器 +- 文件树形结构展示 +- 支持创建、重命名、删除文件/文件夹 +- 支持压缩包上传解压 +- 支持多文件批量上传 + +### 在线终端 +- WebSocket 实时终端 +- 支持常用 Shell 命令 +- 命令执行结果实时输出 + +### 执行日志 +- 任务执行历史记录 +- 执行状态追踪(成功/失败/超时) +- 执行耗时统计 +- 日志内容压缩存储 +- 日志自动清理 + +### 消息推送与系统通知 +- 原生内置各大主流平台渠道(钉钉、企业微信、Telegram、Server酱等) +- 支持系统级事件条件触发通知(例如任务失败报警、服务下线提醒) +- 自动生成跨语言调用 API 示例代码供脚本集成 + +### 变量与机密 +- 支持普通环境变量与安全机密(Secret)分类管理 +- 机密使用 **AES-GCM** 加密存储,数据库不存明文 +- 秘钥仅在内存中留存,启动读取后立即销毁(Unset) +- 执行日志自动搜索并**屏蔽(********)**敏感机密内容 +- 严格权限隔离:机密仅在定时任务调度时注入,终端/测试环境不可见 + +### 节点互联体系 (New) +- 支持 OpenConnect 协议,轻松实现多台任务池之间的互联互通 +- 支持节点间的环境变量无缝同步(保留原有结构与 ID) +- 集成同步管理控制台,统一查看与控制各节点状态与负载指标 +- 底层路由深度适配穿透隧道,完美支持分布式前端代理访问 + +### 仓库任务同步 (New) +- 支持 青龙 仓库命令格式快捷导入 +- 自动解析脚本注释中的 Cron 表达式和环境变量名 +- 支持基于正则表达式的白名单、黑名单文件筛选 +- 支持脚本依赖文件的识别与保留 +- 自动同步远程 Git 仓库变更,增量更新面板任务 + +### 系统设置 +- 站点标题、标语、图标自定义 +- 分页大小、Cookie 有效期配置 +- 调度参数热重载 +- 数据备份与恢复 + +
+ +## 支持语言脚本和依赖 + +
+点击展开查看已支持的语言及依赖管理详情 + +### 脚本运行环境 +任务池原生支持以下脚本的定时执行: +- **Python3**, **Node.js**, **Bash** (标准版镜像内置环境) +- 通过 **Mise** 扩展:支持几乎所有主流编程语言的动态安装与切换。 +- **Minimal 版**:不预置 Python/Node,仅内置 Mise 底座,由用户按需安装。 + +### 依赖管理支持 +系统内置了高度集成的跨语言依赖管理器,支持自动化安装和管理以下语言的依赖项,并确保在容器内全局可用: + +| 语言 | 包管理器 | 功能说明 | +| :--- | :--- | :--- | +| **Python** | pip | 自动使用内置虚拟环境,支持清华源 | +| **Node.js** | npm | 全局安装模式,自动配置 npmmirror 镜像 | +| **Go** | go install | 通过 `go install` 安装二进制工具 | +| **Rust** | cargo | 通过 `cargo install` 安装 Rust 依赖 | +| **Ruby** | gem | 支持 `gem install` 本地安装 | +| **Bun** | bun | 支持 `bun add -g` 全局模式 | +| **PHP** | composer | 支持 `composer global require` | +| **Deno** | deno | 支持 `deno install -g` | +| **.NET** | dotnet | 支持 `dotnet tool install -g` | +| **Elixir/Erlang** | mix | 支持 `mix archive.install` | +| **Lua** | luarocks | 通过 `luarocks` 管理 Lua 包 | +| **Nim** | nimble | 支持 `nimble install` | +| **Dart/Flutter** | pub | 支持 `pub global activate` | +| **Perl** | cpanm | 简单的 `cpanm` 安装支持 | +| **Crystal** | shards | `shards` 项目级别或工具安装 | + +### 使用方法 +1. **安装环境**:进入「编程语言」页面,使用 `mise` 一键安装所需的语言及版本。 +2. **依赖管理**:在已安装列表点击「依赖管理」,输入名称(可选版本)即可自动在对应环境内完成安装。 +3. **隔离机制**:系统基于 `mise exec` 实现了完善的环境隔离,不同版本的依赖包互不冲突。 + +
+ +## 效果图 + +![taskpool-display.gif](https://raw.githubusercontent.com/engigu/resources/refs/heads/images/taskpool-display.gif) + + +## 快速部署 + +项目提供多种基础镜像,可根据具体环境选择: + +| 标签 (Tag) | 基础镜像 | 说明 | +| :--- | :--- | :--- | +| `latest` | Debian 12 | **默认推荐**:集成 Python 3.13 与 Node.js 23,开箱即用 | +| `latest-debian13` | Debian 13 | 尝鲜版本,基于 Debian Trixie | +| `latest-minimal` | Debian 13 | **最小化版**:不预置语言环境,由用户通过面板自主按需安装 | + +> **提示**:下方部署示例默认使用 `latest` 标签,如需换用 Debian 13 版,只需将 `latest` 替换为 `latest-debian13` 即可。 + +> **警告**:**架构升级破坏性变更** +> +> 本版本(2026.02.13+)对底层运行时环境进行了彻底重构,弃用了原有的静态 Python/Node 环境,转为使用 **Mise** 进行动态版本管理。 +> +> 1. **不再提供 Alpine 镜像**:由于 glibc 兼容性问题,Mise 无法在 Alpine 上完美运行,因此暂时取消 Alpine 镜像支持。 +> 2. **环境数据不兼容**:如果您是从旧版本升级上来,原有的 Python/Node 环境数据将无法迁移。升级后您需要: +> - 清空或备份原有的 `envs/` 挂载目录 +> - 启动新容器,让系统自动初始化新的 Mise 环境 +> - 在面板中重新安装所需的语言和依赖 + + +
+方式一:环境变量部署(推荐) + +通过环境变量指定配置,简单灵活,适合容器编排场景。 + +**使用 SQLite(默认):** + +```bash +docker run -d \ + --name taskpool \ + -p 8052:8052 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/envs:/app/envs \ + -e TZ=Asia/Shanghai \ + -e BH_SERVER_PORT=8052 \ + -e BH_SERVER_HOST=0.0.0.0 \ + -e BH_DB_TYPE=sqlite \ + -e BH_DB_PATH=/app/data/taskpool.db \ + -e BH_DB_TABLE_PREFIX=taskpool_ \ + -e TASKPOOL_SECRET_KEY=your_secret_key_here \ + --restart unless-stopped \ + ghcr.io/engigu/taskpool:latest +``` + +> **提示**:如需通过反向代理部署在子路径(如 `/taskpool`),添加环境变量: +> ```bash +> -e BH_SERVER_URL_PREFIX=/taskpool +> ``` +> 配置后访问地址为 `http://your-domain.com/taskpool/`,详见下方「URL 前缀配置」说明。 + +**Docker Compose(SQLite):** + +```yaml +services: + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=sqlite + - BH_DB_PATH=/app/data/taskpool.db + - BH_DB_TABLE_PREFIX=taskpool_ + - TASKPOOL_SECRET_KEY=your_secret_key_here + # - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀用于反向代理 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped +``` + +**使用 MySQL:** + +```bash +docker run -d \ + --name taskpool \ + -p 8052:8052 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/envs:/app/envs \ + -e TZ=Asia/Shanghai \ + -e BH_SERVER_PORT=8052 \ + -e BH_SERVER_HOST=0.0.0.0 \ + -e BH_DB_TYPE=mysql \ + -e BH_DB_HOST=mysql-server \ + -e BH_DB_PORT=3306 \ + -e BH_DB_USER=root \ + -e BH_DB_PASSWORD=your_password \ + -e BH_DB_NAME=taskpool \ + -e BH_DB_TABLE_PREFIX=taskpool_ \ + -e TASKPOOL_SECRET_KEY=your_secret_key_here \ + --restart unless-stopped \ + ghcr.io/engigu/taskpool:latest +``` + +> **提示**:如需配置 URL 前缀,添加 `-e BH_SERVER_URL_PREFIX=/taskpool` + +**Docker Compose(MySQL):** + +```yaml +services: + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + # - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀 + - BH_DB_TYPE=mysql + - BH_DB_HOST=mysql-server + - BH_DB_PORT=3306 + - BH_DB_USER=root + - BH_DB_PASSWORD=your_password + - BH_DB_NAME=taskpool + - BH_DB_TABLE_PREFIX=taskpool_ + - TASKPOOL_SECRET_KEY=your_secret_key_here + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped +``` + +
+ +
+方式二:配置文件部署 + +通过挂载 `config.ini` 配置文件来管理配置,适合需要持久化配置的场景。 + +**Docker 命令:** + +```bash +docker run -d \ + --name taskpool \ + -p 8052:8052 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/configs:/app/configs \ + -v $(pwd)/envs:/app/envs \ + -e TZ=Asia/Shanghai \ + -e TASKPOOL_SECRET_KEY=your_secret_key_here \ + --restart unless-stopped \ + ghcr.io/engigu/taskpool:latest +``` + +**Docker Compose:** + +```yaml +services: + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./configs:/app/configs + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - TASKPOOL_SECRET_KEY=your_secret_key_here + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped +``` + +首次使用需要复制 `configs/config.example.ini` 为 `configs/config.ini`,然后根据需要修改配置。 + +**配置文件示例(`configs/config.ini`):** + +```ini +[server] +port = 8052 +host = 0.0.0.0 +# 可选:配置 URL 前缀用于反向代理,例如 /taskpool +url_prefix = +# 全局会话 Cookie 名称 +cookie_name = BHToken + +[database] +type = sqlite +path = ./data/taskpool.db +table_prefix = taskpool_ +``` + +
+ +
+方式三:配合独立中心化消息服务部署(非必需/仅供参考) + +> 🎉 **好消息**:自任务池最新版本起,系统已**原生内置**了完整强大的消息推送功能!您可直接在面板「消息推送」菜单内绑定十余种主流渠道和系统通知事件,原配合外置的 `Message-Push-Nest` 部署方式已不再是使用面板的基础要求。您可随时直接使用上方的第一种简单命令开箱即用体验。 +> +> 以下「任务池 + 消息聚合服务」的联合部署内容被予以保留,专为仍然需要「中心化通知网关」的重度企业解耦用户作为参考: + +任务池通过系统集成也可轻松连接独立的消息聚合服务。这里推荐使用 [Message-Push-Nest](https://github.com/engigu/Message-Push-Nest) 作为分布式的统一消息推送中心。 + +**使用 SQLite** + +创建 `docker-compose.yml` 文件: + +```yaml +services: + # 任务池 + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=sqlite + - BH_DB_PATH=/app/data/taskpool.db + - BH_DB_TABLE_PREFIX=taskpool_ + - TASKPOOL_SECRET_KEY=your_secret_key_here + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped + depends_on: + - message-nest + + # 消息推送服务 + message-nest: + image: ghcr.io/engigu/message-nest:latest + # 或使用 Docker Hub 镜像 + # image: engigu/message-nest:latest + container_name: message-nest + ports: + - "8053:8000" + environment: + - TZ=Asia/Shanghai + - DB_TYPE=sqlite + volumes: + - ./message-nest-data:/app/data + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped +``` + +**使用 MySQL(适合生产环境,需要已有 MySQL 服务)** + +创建 `docker-compose.yml` 文件: + +```yaml +services: + # 任务池 + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=mysql + - BH_DB_HOST=192.168.1.100 # 修改为你的 MySQL 地址 + - BH_DB_PORT=3306 + - BH_DB_USER=root + - BH_DB_PASSWORD=your_password # 修改为你的 MySQL 密码 + - BH_DB_NAME=taskpool + - BH_DB_TABLE_PREFIX=taskpool_ + - TASKPOOL_SECRET_KEY=your_secret_key_here + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped + depends_on: + - message-nest + + # 消息推送服务 + message-nest: + image: ghcr.io/engigu/message-nest:latest + # 或使用 Docker Hub 镜像 + # image: engigu/message-nest:latest + container_name: message-nest + ports: + - "8053:8000" + environment: + - TZ=Asia/Shanghai + - DB_TYPE=mysql + - MYSQL_HOST=192.168.1.100 # 修改为你的 MySQL 地址 + - MYSQL_PORT=3306 + - MYSQL_USER=root + - MYSQL_PASSWORD=your_password # 修改为你的 MySQL 密码 + - MYSQL_DB=message_nest + - MYSQL_TABLE_PREFIX=message_ + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped +``` + +启动服务: + +```bash +docker-compose up -d +``` + +**访问地址:** +- 任务池:http://localhost:8052 +- 消息推送服务:http://localhost:8053 + +> 注意:使用 MySQL 方式时,请先在 MySQL 中创建 `taskpool` 和 `message_nest` 两个数据库,并修改配置中的 MySQL 地址和密码。也可以使用同一个数据库。 + +**在任务中使用推送** + +如今你无需依赖任何外部服务,可以通过面板自身完成: +1. 进入任务池「消息推送」模块,新建你需要通知的渠道。 +2. 可以在「事件绑定」中直接设定**自动化系统通知**(如监控任务失败或脚本异常中止时进行自动提醒)。 +3. 如需在自己的脚本逻辑内动态推送,可以点击「脚本调用」页面,立刻获取一键调用的 Shell / API 代码,内嵌进你的业务逻辑中即可完成推信! + +*(如继续使用 `Message-Push-Nest`,你可以通过其管理界面中「消息模板」的「复制推送代码」提取旧有版集成样例。)* + +![1768143124572.png](https://f.pz.al/pzal/2026/01/11/1360cd334ff20.png) + +> 提示:在 Docker Compose 部署的环境中,推送服务地址使用 `http://message-nest:8000`(容器内部通信)。如果是独立部署,请使用实际的服务地址。 + +
+ +> 环境变量优先级高于配置文件,两种方式可以混合使用。 + +
+方式四:Nginx 反向代理部署(HTTPS) + +如果需要通过域名和 HTTPS 访问任务池,可以使用 Nginx 作为反向代理。 + +**Nginx 配置示例:** + +```nginx +# 在 http 块中添加 WebSocket 升级配置 +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl http2; + server_name example.com; + + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + access_log /var/log/nginx/example.access.log; + error_log /var/log/nginx/example.error.log warn; + + location / { + proxy_pass http://172.17.0.1:8052; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # WebSocket 支持(终端功能需要) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_buffering off; + proxy_read_timeout 60s; + } +} + +# HTTP 自动跳转 HTTPS(可选) +server { + listen 80; + server_name example.com; + return 301 https://$server_name$request_uri; +} +``` + +**配置说明:** + +1. 将 `example.com` 替换为你的域名 +2. 修改 SSL 证书路径为你的实际路径 +3. `172.17.0.1:8052` 是 Docker 容器的宿主机地址和端口,根据实际情况修改 +4. WebSocket 配置是必需的,否则在线终端功能无法使用 + + +**重载 Nginx 配置:** + +```bash +nginx -t && nginx -s reload +``` + +
+ + +### 访问面板 + +启动后访问:http://localhost:8052 + +**默认账号:** 用户名 `admin`,密码见启动日志(首次启动会自动生成 12 位随机密码并打印在日志中) + +> **注意**:出于安全性考虑,系统不再使用固定默认密码。请在容器启动日志中搜索 `管理员账号创建成功` 找到您的随机密码,并登录后及时修改。 + +
+命令行工具 (CLI) + +任务池在环境内内置了同名的 `taskpool` 命令行工具。如果您在终端内需要执行系统级别的操作,可以使用以下命令: + +```bash +taskpool server # 以前台方式启动面板的后台进程服务(面板启动指令) +taskpool reposync # 供定时任务调用,将远程 Git 仓库的高级特性同步到本地目录中 +taskpool resetpwd # 交互式重置系统 admin 账号密码(密码丢失时可通过进入终端重置) +taskpool restore # 使用本地的 .zip 备份压缩包文件,一条命令直接全量恢复系统数据 +``` + +终端执行 `taskpool` 会直接打印内置支持的高级命令帮助列表。 + +
+ +### 数据目录 + +``` +./ +├── taskpool # 可执行文件 +├── data/ # 数据目录(自动创建) +│ ├── taskpool.db # SQLite 数据库 +│ └── scripts/ # 脚本文件存储 +├── configs/ +│ └── config.ini # 配置文件(自动创建) +└── envs/ # 运行环境挂载目录(自动创建) + └── mise/ # Mise 运行时核心目录 (包含所有语言环境及依赖) +``` + +### Docker 启动流程 + +容器启动时 `docker-entrypoint.sh` 会执行以下操作: + +1. **目录就绪**:检查并创建 `/app/data`、`/app/configs`、`/app/envs` 等核心目录。 +2. **Mise 环境同步**:自动从镜像内置基础环境同步初始化文件至 `/app/envs/mise`,确保持久化挂载后运行时依然可用。 +3. **运行时激活**: + - 自动注入 `MISE_DATA_DIR` 等环境变量,确保运行时数据指向持久化目录。 + - 将 `mise shims` 路径加入系统 `PATH`,实现 Python、Node.js 等多版本环境的全局无感调用。 +4. **依赖管理预设**:默认配置 Python 清华源(PIP)镜像,优化 Node.js 默认内存上限。 +5. **启动应用**:运行 `taskpool` 面板主进程。 + +> **提示**:通过挂载 `./envs:/app/envs`,您通过面板安装的所有编程语言运行时以及通过「依赖管理」安装的所有第三方库都会永久保留,容器升级或重启后无需重新安装。 + +## 配置说明 + +
+点击展开查看配置详情 + +### 配置文件 + +配置文件路径:`configs/config.ini` + +```ini +[server] +port = 8052 +host = 0.0.0.0 +url_prefix = + +[database] +type = sqlite +host = localhost +port = 3306 +user = root +password = +dbname = bh_panel +table_prefix = taskpool_ +``` + +### 环境变量 + +所有配置项都支持通过环境变量覆盖,环境变量优先级高于配置文件: + +| 环境变量 | 对应配置 | 说明 | 默认值 | +|----------|----------|------|--------| +| `BH_SERVER_PORT` | server.port | 服务端口 | 8052 | +| `BH_SERVER_HOST` | server.host | 监听地址 | 0.0.0.0 | +| `BH_SERVER_URL_PREFIX` | server.url_prefix | URL 前缀,用于反向代理子路径部署 | - | +| `BH_COOKIE_NAME` | server.cookie_name | 全局会话 Cookie 名称 | BHToken | +| `BH_DB_TYPE` | database.type | 数据库类型 (sqlite/mysql) | sqlite | +| `BH_DB_HOST` | database.host | 数据库地址 | localhost | +| `BH_DB_PORT` | database.port | 数据库端口 | 3306 | +| `BH_DB_USER` | database.user | 数据库用户 | root | +| `BH_DB_PASSWORD` | database.password | 数据库密码 | - | +| `BH_DB_NAME` | database.dbname | 数据库名称 | bh_panel | +| `BH_DB_PATH` | database.path | SQLite 文件路径 | ./data/taskpool.db | +| `BH_DB_TABLE_PREFIX` | database.table_prefix | 表前缀 | taskpool_ | +| `TASKPOOL_SECRET_KEY` | - | 系统加密秘钥,用于机密功能(**注:仅支持环境变量设置,不支持配置文件**) | - | + +### URL 前缀配置 + +如果需要通过反向代理(如 Nginx)将任务池部署在子路径下,可以配置 URL 前缀。 + +**配置方式:** + +```bash +# 方式一:配置文件 +[server] +url_prefix = /taskpool + +# 方式二:环境变量 +-e BH_SERVER_URL_PREFIX=/taskpool +``` + +**配置效果:** + +配置 `url_prefix = /taskpool` 后,访问路径变为: + +| 类型 | 路径示例 | +|------|---------| +| 前端页面 | `http://your-domain.com/taskpool/` | +| 登录页面 | `http://your-domain.com/taskpool/login` | +| 任务管理 | `http://your-domain.com/taskpool/tasks` | +| API 接口 | `http://your-domain.com/taskpool/api/v1/*` | +| WebSocket | `ws://your-domain.com/taskpool/api/v1/terminal/ws` | + +**Nginx 反向代理配置示例:** + +```nginx +location /taskpool/ { + proxy_pass http://localhost:8052/taskpool/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +**MySQL 示例:** + +参考上方「方式一:环境变量部署」中的 MySQL 配置示例。 + +### 调度设置 + +系统采用 Worker Pool + 任务队列的架构来控制任务执行,可在「系统设置 > 调度设置」中配置: + +| 设置项 | 说明 | 默认值 | +|--------|------|--------| +| Worker 数量 | 并发执行任务的 worker 数量 | 4 | +| 队列大小 | 任务队列缓冲区大小 | 100 | +| 速率间隔 | 任务启动间隔(毫秒) | 200 | + +修改调度设置后立即生效,无需重启服务。 + +
+ +## 免责声明 ⚠️ + +任务池(TaskPool)仅作为一个轻量级的任务托管与调度平台,本项目及相关代码**不提供、不内置任何具有实际业务逻辑的第三方脚本**。 + +在使用本项目时,请您务必知悉并同意以下条款: + +1. **脚本来源审核**:请勿轻易执行任何来源不明或不可信的外部脚本。所有在平台上运行的脚本及代码均需由用户自行添加或配置,用户必须在执行前仔细阅读并审核其源代码,确保其安全性。 +2. **安全责任自负**:本项目作为基础调度工具,**无法且不保证任何被执行任务的安全性**。因运行不安全、违规脚本带来的一切数据泄露、系统损坏、财产损失及法律责任等后果,均由使用者自行承担,与本项目及开发者无关。 +3. **软件按“原样”提供**:本项目为业余开源开发,按“原样”提供,**不保证不存在 Bug 或漏洞**。开发者不对因使用本项目而引起的任何直接或间接损失负责。 + +## 贡献 + +欢迎提交 Issue 和 Pull Request!如果觉得本项目对你有帮助,不求大富大贵,只求顺手点个 Star,大家的 Star 是我持续更新的动力! + + + +## 许可证 + +本项目采用 [Apache License 2.0](LICENSE) 协议发布,并包含额外的 [NOTICE](NOTICE) 说明。 + +**强制要求:** 在任何分发、修改或二次开发中,**必须完整保留原作者署名及项目名称**(详见 NOTICE 文件)。 diff --git a/agent.air.toml b/agent.air.toml new file mode 100644 index 0000000..bcd0350 --- /dev/null +++ b/agent.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "bin" + +[build] + args_bin = [] + bin = "./bin/taskpool-agent" + cmd = "go build -o ./bin/taskpool-agent ./agent" + full_bin = "./bin/taskpool-agent run -c ../agent/config.ini" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "web", "data", "envs", "configs", "bin"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "agent-build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/agent/agent.go b/agent/agent.go new file mode 100644 index 0000000..0c8b66f --- /dev/null +++ b/agent/agent.go @@ -0,0 +1,844 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/utils" + "github.com/gorilla/websocket" +) + +// WebSocket 消息类型 +const ( + WSTypeHeartbeat = constant.WSTypeHeartbeat + WSTypeHeartbeatAck = constant.WSTypeHeartbeatAck + WSTypeTasks = constant.WSTypeTasks + WSTypeTaskResult = constant.WSTypeTaskResult + WSTypeUpdate = constant.WSTypeUpdate + WSTypeConnected = constant.WSTypeConnected + WSTypeDisabled = constant.WSTypeDisabled + WSTypeEnabled = constant.WSTypeEnabled + WSTypeFetchTasks = constant.WSTypeFetchTasks + WSTypeTaskLog = constant.WSTypeTaskLog + WSTypeExecute = constant.WSTypeExecute + WSTypeTaskHeartbeat = constant.WSTypeTaskHeartbeat + WSTypeStop = constant.WSTypeStop +) + +type WSMessage struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` +} + +type AgentTask struct { + ID string `json:"id"` + Name string `json:"name"` + Command string `json:"command"` + PreCommand string `json:"pre_command"` + PostCommand string `json:"post_command"` + Schedule string `json:"schedule"` + Cron string `json:"cron"` + Timeout int `json:"timeout"` + WorkDir string `json:"work_dir"` + Envs string `json:"envs"` + Languages []map[string]string `json:"languages"` + RandomRange int `json:"random_range"` + Secrets []string `json:"secrets"` + Enabled bool `json:"enabled"` +} + +func (t *AgentTask) GetID() string { + return t.ID +} + +func (t *AgentTask) GetName() string { + return t.Name +} + +func (t *AgentTask) GetCommand() string { + return t.Command +} + +func (t *AgentTask) GetPreCommand() string { + return t.PreCommand +} + +func (t *AgentTask) GetPostCommand() string { + return t.PostCommand +} + +func (t *AgentTask) GetTimeout() int { + return t.Timeout +} + +func (t *AgentTask) GetWorkDir() string { + return t.WorkDir +} + +func (t *AgentTask) GetEnvs() string { + return t.Envs +} + +func (t *AgentTask) GetEnvVars() []string { + return nil +} + +func (t *AgentTask) GetSecrets() []string { + return t.Secrets +} + +func (t *AgentTask) GetLanguages() []map[string]string { + return t.Languages +} + +func (t *AgentTask) GetUseMise() bool { + return false +} + +func (t *AgentTask) UseMise() bool { + return false +} + +func (t *AgentTask) GetSchedule() string { + if t.Schedule != "" { + return t.Schedule + } + return t.Cron +} + +func (t *AgentTask) GetRandomRange() int { + return t.RandomRange +} + +type TaskResult struct { + TaskID string `json:"task_id"` + LogID string `json:"log_id"` + AgentID string `json:"agent_id"` // 仅用于 HTTP 上报时后端补充 + Command string `json:"command"` + Output string `json:"output"` + Error string `json:"error"` + Status string `json:"status"` + Duration int64 `json:"duration"` + ExitCode int `json:"exit_code"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` +} + +type Agent struct { + config *Config + configFile string + machineID string + scheduler *executor.Scheduler + cronManager *executor.CronManager + tasks map[string]*AgentTask // 本地任务缓存,用于执行 lookup + lastTaskCount int + mu sync.RWMutex + client *http.Client + wsConn *websocket.Conn + wsMu sync.Mutex + stopCh chan struct{} + wsStopCh chan struct{} // 用于停止当前 WebSocket 相关的 goroutine + taskLogs map[string][]string // 记录最近的日志行,用于失败显示 + logMu sync.Mutex // taskLogs 的锁 + schedulerStarted bool // 调度器是否已经启动 +} + +func NewAgent(config *Config, configFile string) *Agent { + a := &Agent{ + config: config, + configFile: configFile, + machineID: utils.GenerateMachineID(), + tasks: make(map[string]*AgentTask), + client: &http.Client{Timeout: 30 * time.Second}, + stopCh: make(chan struct{}), + lastTaskCount: -1, + taskLogs: make(map[string][]string), + } + + // 初始化调度器 + handler := &AgentHandler{agent: a} + schedCfg := executor.SchedulerConfig{ + WorkerCount: runtime.NumCPU(), + QueueSize: 100, + RateInterval: 100 * time.Millisecond, + Verbose: true, + } + a.scheduler = executor.NewScheduler(schedCfg, handler) + a.scheduler.SetLogger(logger.NewSchedulerLogger()) + a.cronManager = executor.NewCronManager(a.scheduler) + a.cronManager.SetLogger(logger.NewSchedulerLogger()) + + return a +} + +// AgentHandler 实现 executor.SchedulerEventHandler +type AgentHandler struct { + agent *Agent +} + +func (h *AgentHandler) OnTaskScheduled(req *executor.ExecutionRequest) {} + +func (h *AgentHandler) OnTaskExecuting(req *executor.ExecutionRequest) (io.Writer, io.Writer, error) { + if req.LogID != "" { + writer := &RealTimeLogWriter{agent: h.agent, logID: req.LogID} + return writer, writer, nil + } + return nil, nil, nil +} + +func (h *AgentHandler) OnTaskHeartbeat(req *executor.ExecutionRequest, duration int64) { + if req.LogID != "" { + h.agent.sendWSMessage(WSTypeTaskHeartbeat, map[string]interface{}{ + "log_id": req.LogID, + "duration": duration, + }) + } + + // 每分钟打印一次任务还在运行的日志,提升长任务的存在感 + if duration >= 60000 && (duration/60000 > (duration-3000)/60000) { + logger.Infof("[Scheduler] 任务 #%s 仍在运行中... (已耗时: %v)", + req.TaskID, (time.Duration(duration) * time.Millisecond).Round(time.Second)) + } +} + +func (h *AgentHandler) OnTaskStarted(req *executor.ExecutionRequest) {} + +func (h *AgentHandler) OnTaskCompleted(req *executor.ExecutionRequest, result *executor.ExecutionResult) { + h.agent.sendTaskResult(&TaskResult{ + TaskID: req.TaskID, + LogID: result.LogID, + Command: req.Command, + Output: result.Output, + Error: result.Error, + Status: result.Status, + Duration: result.Duration, + ExitCode: result.ExitCode, + StartTime: result.StartTime.Unix(), + EndTime: result.EndTime.Unix(), + }) + + if result.Status == constant.TaskStatusFailed { + h.agent.printLastLogs(result.LogID) + } + h.agent.clearTaskLog(result.LogID) +} + +func (h *AgentHandler) OnTaskFailed(req *executor.ExecutionRequest, err error) { + errMsg := fmt.Sprintf("任务执行失败: %v", err) + // 先发送日志,确保服务端能收到错误信息 + h.agent.sendWSMessage(WSTypeTaskLog, map[string]interface{}{ + "log_id": req.LogID, + "content": errMsg, + }) + + h.agent.sendTaskResult(&TaskResult{ + TaskID: req.TaskID, + LogID: req.LogID, + Command: req.Command, + Output: "", + Error: err.Error(), + Status: constant.TaskStatusFailed, + Duration: 0, + ExitCode: 1, + StartTime: time.Now().Unix(), + EndTime: time.Now().Unix(), + }) + + h.agent.printLastLogs(req.LogID) + h.agent.clearTaskLog(req.LogID) +} + +func (h *AgentHandler) OnCronNextRun(req *executor.ExecutionRequest, nextRun time.Time) {} + +func (a *Agent) Start() error { + if a.config.Token == "" { + return fmt.Errorf("缺少令牌,请在配置文件中设置 token") + } + + logger.Infof("机器识别码: %s", a.machineID[:16]+"...") + // 调度器暂不在此启动,等待 WebSocket 连接成功并获取到调度配置后再启动 + go a.wsLoop() + + logger.Info("Agent 已启动 (时区: Asia/Shanghai, 模式: WebSocket)") + return nil +} + +func (a *Agent) Stop() { + close(a.stopCh) + a.closeWS() + + a.mu.Lock() + started := a.schedulerStarted + a.schedulerStarted = false + a.mu.Unlock() + + if started { + a.cronManager.Stop() + a.scheduler.Stop() + } + logger.Info("Agent 已停止") +} + +// wsLoop WebSocket 连接循环 +func (a *Agent) wsLoop() { + for { + select { + case <-a.stopCh: + return + default: + } + + if err := a.connectWS(); err != nil { + logger.Warnf("WebSocket 连接失败: %v,5秒后重试...", err) + time.Sleep(5 * time.Second) + continue + } + + a.readWS() + + logger.Warn("WebSocket 连接断开,5秒后重连...") + time.Sleep(5 * time.Second) + } +} + +func (a *Agent) connectWS() error { + serverURL := a.config.ServerURL + wsURL := strings.Replace(serverURL, "http://", "ws://", 1) + wsURL = strings.Replace(wsURL, "https://", "wss://", 1) + wsURL = fmt.Sprintf("%s/api/agent/ws?token=%s&machine_id=%s", wsURL, url.QueryEscape(a.config.Token), url.QueryEscape(a.machineID)) + + logger.Infof("正在连接 WebSocket: %s", wsURL) + logger.Infof("Token: %s..., MachineID: %s...", a.config.Token[:8], a.machineID[:16]) + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, resp, err := dialer.Dial(wsURL, nil) + if err != nil { + if resp != nil { + bodyBytes, _ := io.ReadAll(resp.Body) + logger.Errorf("WebSocket 握手失败: HTTP %d, Body: %s", resp.StatusCode, string(bodyBytes)) + resp.Body.Close() + } else { + logger.Errorf("WebSocket 连接失败: %v", err) + } + return err + } + + a.wsMu.Lock() + a.wsConn = conn + a.wsStopCh = make(chan struct{}) + a.wsMu.Unlock() + + logger.Info("WebSocket 已连接") + a.sendHeartbeat() + go a.heartbeatLoop() + + return nil +} + +func (a *Agent) closeWS() { + a.wsMu.Lock() + defer a.wsMu.Unlock() + if a.wsStopCh != nil { + close(a.wsStopCh) + a.wsStopCh = nil + } + if a.wsConn != nil { + a.wsConn.Close() + a.wsConn = nil + } +} + +func (a *Agent) readWS() { + defer func() { + logger.Info("readWS 退出,准备关闭连接") + a.closeWS() + }() + + for { + a.wsMu.Lock() + conn := a.wsConn + a.wsMu.Unlock() + + if conn == nil { + logger.Warn("readWS: wsConn 为 nil") + return + } + + _, message, err := conn.ReadMessage() + if err != nil { + logger.Warnf("WebSocket 读取错误: %v", err) + return + } + + var msg WSMessage + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + a.handleWSMessage(&msg) + } +} + +func (a *Agent) handleWSMessage(msg *WSMessage) { + switch msg.Type { + case WSTypeConnected: + a.handleConnected(msg.Data) + case WSTypeHeartbeatAck: + a.handleHeartbeatAck(msg.Data) + case WSTypeTasks: + a.handleTasks(msg.Data) + case WSTypeUpdate: + logger.Info("收到更新指令,开始更新...") + go a.selfUpdate() + case WSTypeDisabled: + logger.Warn("Agent 已被禁用,清空所有任务") + a.clearAllTasks() + case WSTypeEnabled: + logger.Info("Agent 已被启用,主动拉取任务") + a.fetchTasks() + case WSTypeExecute: + a.handleExecute(msg.Data) + case WSTypeStop: + a.handleStop(msg.Data) + } +} + +func (a *Agent) fetchTasks() { + logger.Info("正在从服务器拉取任务列表...") + if err := a.sendWSMessage(WSTypeFetchTasks, map[string]interface{}{}); err != nil { + logger.Warnf("请求任务列表失败: %v", err) + } +} + +func (a *Agent) handleConnected(data json.RawMessage) { + var resp struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + IsNewAgent bool `json:"is_new_agent"` + MachineID string `json:"machine_id"` + SchedulerConfig map[string]interface{} `json:"scheduler_config"` + } + json.Unmarshal(data, &resp) + + if resp.IsNewAgent { + logger.Infof("注册成功: Agent #%s, 机器码: %s", resp.AgentID, a.machineID[:16]+"...") + } else { + logger.Infof("连接成功: Agent #%s (已存在), 机器码: %s", resp.AgentID, a.machineID[:16]+"...") + } + + // 更新调度器配置 + if resp.SchedulerConfig != nil { + a.updateSchedulerConfig(resp.SchedulerConfig) + } + + a.fetchTasks() +} + +func (a *Agent) updateSchedulerConfig(config map[string]interface{}) { + // 获取当前配置作为基础 + currentCfg := a.scheduler.GetConfig() + newCfg := currentCfg + + // 更新配置项 + if val, ok := config["worker_count"]; ok { + if v, ok := val.(float64); ok { // JSON 数字解析为 float64 + newCfg.WorkerCount = int(v) + } + } + if val, ok := config["queue_size"]; ok { + if v, ok := val.(float64); ok { + newCfg.QueueSize = int(v) + } + } + if val, ok := config["rate_interval"]; ok { + if v, ok := val.(float64); ok { + newCfg.RateInterval = time.Duration(v) * time.Millisecond + } + } + if val, ok := config["strict_queue"]; ok { + if v, ok := val.(bool); ok { + newCfg.StrictQueue = v + } + } + + a.mu.Lock() + started := a.schedulerStarted + a.schedulerStarted = true + a.mu.Unlock() + + if !started { + logger.Infof("首次连接成功,启动调度器配置: workers=%d, queue=%d, rate=%v, strict=%t", + newCfg.WorkerCount, newCfg.QueueSize, newCfg.RateInterval, newCfg.StrictQueue) + // 用下发的最新配置加载并启动调度器与计划任务管理器 + a.scheduler.Reload(newCfg) + a.cronManager.Start() + } else if newCfg != currentCfg { + logger.Infof("收到调度配置更新: workers=%d, queue=%d, rate=%v, strict=%t", + newCfg.WorkerCount, newCfg.QueueSize, newCfg.RateInterval, newCfg.StrictQueue) + a.scheduler.Reload(newCfg) + } else { + logger.Infof("当前调度配置未改变: workers=%d, queue=%d, rate=%v, strict=%t", + newCfg.WorkerCount, newCfg.QueueSize, newCfg.RateInterval, newCfg.StrictQueue) + } +} + +func (a *Agent) handleHeartbeatAck(data json.RawMessage) { + var resp struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + NeedUpdate bool `json:"need_update"` + ForceUpdate bool `json:"force_update"` + LatestVersion string `json:"latest_version"` + } + json.Unmarshal(data, &resp) + + if resp.NeedUpdate && (a.config.AutoUpdate || resp.ForceUpdate) { + logger.Infof("发现新版本 %s,开始更新...", resp.LatestVersion) + go a.selfUpdate() + } +} + +func (a *Agent) handleTasks(data json.RawMessage) { + var resp struct { + Tasks []AgentTask `json:"tasks"` + } + json.Unmarshal(data, &resp) + + newCount := len(resp.Tasks) + if newCount != a.lastTaskCount || newCount == 0 { + logger.Infof("任务列表同步成功: 共获取到 %d 个任务", newCount) + a.lastTaskCount = newCount + } + + a.updateTasks(resp.Tasks) +} + +func (a *Agent) handleExecute(data json.RawMessage) { + var req struct { + TaskID string `json:"task_id"` + LogID string `json:"log_id"` + Envs string `json:"envs"` + Secrets []string `json:"secrets"` + Command string `json:"command"` + PreCommand string `json:"pre_command"` + PostCommand string `json:"post_command"` + } + if err := json.Unmarshal(data, &req); err != nil { + logger.Errorf("解析立即执行请求失败: %v", err) + return + } + + // 查找任务 + a.mu.RLock() + task, exists := a.tasks[req.TaskID] + a.mu.RUnlock() + + if !exists { + logger.Warnf("任务 #%s 不存在,无法执行", req.TaskID) + return + } + + // 准备执行请求 + // 如果消息中携带了环境变量或指令,则优先使用(确保即时生效) + envs := task.Envs + if req.Envs != "" { + envs = req.Envs + } + + command := task.Command + if req.Command != "" { + command = req.Command + } + + preCommand := task.PreCommand + if req.PreCommand != "" { + preCommand = req.PreCommand + } + + postCommand := task.PostCommand + if req.PostCommand != "" { + postCommand = req.PostCommand + } + + execReq := &executor.ExecutionRequest{ + TaskID: task.ID, + LogID: req.LogID, + Name: task.Name, + Command: command, + PreCommand: preCommand, + PostCommand: postCommand, + WorkDir: task.WorkDir, + Envs: executor.ParseEnvVars(envs), + Secrets: req.Secrets, + Timeout: task.Timeout, + Languages: task.Languages, + UseMise: task.UseMise(), + Type: executor.TaskTypeManual, + } + + // 立即执行任务(加入队列) + a.scheduler.EnqueueOrExecute(execReq) +} + +func (a *Agent) handleStop(data json.RawMessage) { + var req struct { + LogID string `json:"log_id"` + } + if err := json.Unmarshal(data, &req); err != nil { + logger.Errorf("解析停止请求失败: %v", err) + return + } + + logger.Infof("[Agent] 收到停止指令 LogID: %s", req.LogID) + if a.scheduler.StopLog(req.LogID) { + logger.Infof("[Agent] 任务执行 #%s 已成功停止", req.LogID) + } else { + logger.Warnf("[Agent] 任务执行 #%s 停止失败(可能已完成或不在运行队列中)", req.LogID) + } +} + +// RealTimeLogWriter 实时日志写入器,通过 WebSocket 发送日志 +type RealTimeLogWriter struct { + agent *Agent + logID string +} + +func (w *RealTimeLogWriter) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } + + // 记录到本地缓存,用于失败时显示 + w.agent.addTaskLog(w.logID, p) + + // 构造消息 + msg := map[string]interface{}{ + "log_id": w.logID, + "content": string(p), + } + + // 发送消息 + if err := w.agent.sendWSMessage(WSTypeTaskLog, msg); err != nil { + // 如果发送失败,不阻塞程序执行,只记录日志 + return len(p), nil + } + + return len(p), nil +} + +func (a *Agent) sendWSMessage(msgType string, data interface{}) error { + a.wsMu.Lock() + defer a.wsMu.Unlock() + + if a.wsConn == nil { + return fmt.Errorf("WebSocket 未连接") + } + + dataBytes, _ := json.Marshal(data) + msg := WSMessage{Type: msgType, Data: dataBytes} + msgBytes, _ := json.Marshal(msg) + + a.wsConn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := a.wsConn.WriteMessage(websocket.TextMessage, msgBytes); err != nil { + logger.Warnf("发送消息失败 (%s): %v", msgType, err) + return err + } + return nil +} + +func (a *Agent) heartbeatLoop() { + ticker := time.NewTicker(time.Duration(a.config.Interval) * time.Second) + defer ticker.Stop() + + a.wsMu.Lock() + wsStopCh := a.wsStopCh + a.wsMu.Unlock() + + if wsStopCh == nil { + return + } + + for { + select { + case <-a.stopCh: + return + case <-wsStopCh: + return + case <-ticker.C: + a.wsMu.Lock() + conn := a.wsConn + a.wsMu.Unlock() + if conn == nil { + return + } + a.sendHeartbeat() + } + } +} + +func (a *Agent) sendHeartbeat() { + hostname, _ := os.Hostname() + data := map[string]interface{}{ + "version": Version, + "build_time": BuildTime, + "hostname": hostname, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "auto_update": a.config.AutoUpdate, + } + if err := a.sendWSMessage(WSTypeHeartbeat, data); err != nil { + logger.Warnf("发送心跳失败: %v", err) + } +} + +func (a *Agent) sendTaskResult(result *TaskResult) { + if err := a.sendWSMessage(WSTypeTaskResult, result); err != nil { + logger.Warnf("发送任务结果失败: %v,尝试 HTTP 上报", err) + a.reportResultHTTP(result) + } +} + +func (a *Agent) reportResultHTTP(result *TaskResult) error { + resp, err := a.doRequest("POST", "/api/agent/report", result) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} + +func (a *Agent) updateTasks(tasks []AgentTask) { + a.mu.Lock() + defer a.mu.Unlock() + + newTasks := make(map[string]*AgentTask) + for i := range tasks { + newTasks[tasks[i].ID] = &tasks[i] + } + + // 1. 移除不再存在的任务 + for id := range a.tasks { + if _, exists := newTasks[id]; !exists { + a.cronManager.RemoveTask(id) + delete(a.tasks, id) + logger.Infof("移除调度任务 #%s", id) + } + } + + // 2. 添加或更新任务 + for id, task := range newTasks { + oldTask, exists := a.tasks[id] + if !exists || oldTask.Schedule != task.Schedule || oldTask.Command != task.Command || + oldTask.PreCommand != task.PreCommand || oldTask.PostCommand != task.PostCommand || + oldTask.Enabled != task.Enabled || oldTask.Timeout != task.Timeout || + oldTask.WorkDir != task.WorkDir || oldTask.Envs != task.Envs || + oldTask.RandomRange != task.RandomRange { + if task.Enabled { + err := a.cronManager.AddTask(task) + if err != nil { + logger.Errorf("添加调度任务 #%s 失败: %v", id, err) + continue + } + logger.Infof("已添加调度任务 #%s %s (%s)", id, task.Name, task.GetSchedule()) + } else { + a.cronManager.RemoveTask(id) + logger.Infof("调度任务 #%s 已禁用", id) + } + a.tasks[id] = task + } + } +} + +func (a *Agent) clearAllTasks() { + a.mu.Lock() + defer a.mu.Unlock() + + for id := range a.tasks { + a.cronManager.RemoveTask(id) + logger.Infof("移除任务 #%s", id) + } + + a.tasks = make(map[string]*AgentTask) + a.lastTaskCount = 0 + logger.Info("所有任务已清空") +} + +func (a *Agent) addTaskLog(logID string, p []byte) { + if logID == "" { + return + } + a.logMu.Lock() + defer a.logMu.Unlock() + + content := string(p) + lines := strings.Split(strings.TrimSuffix(content, "\n"), "\n") + + a.taskLogs[logID] = append(a.taskLogs[logID], lines...) + if len(a.taskLogs[logID]) > 50 { + a.taskLogs[logID] = a.taskLogs[logID][len(a.taskLogs[logID])-50:] + } +} + +func (a *Agent) printLastLogs(logID string) { + if logID == "" { + return + } + a.logMu.Lock() + lines, ok := a.taskLogs[logID] + a.logMu.Unlock() + + if !ok || len(lines) == 0 { + return + } + + logger.Errorf("--- 任务 #%s 失败日志预览 (最近 %d 行) ---", logID, len(lines)) + for _, line := range lines { + fmt.Println(" " + line) + } + logger.Errorf("--- 任务 #%s 结束 ---", logID) +} + +func (a *Agent) clearTaskLog(logID string) { + if logID == "" { + return + } + a.logMu.Lock() + defer a.logMu.Unlock() + delete(a.taskLogs, logID) +} + +// executeTask 已被 AgentHandler.OnTaskCompleted 代替,此处删除旧实现 + +func (a *Agent) doRequest(method, path string, body interface{}) (*http.Response, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, a.config.ServerURL+path, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+a.config.Token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Machine-ID", a.machineID) + + return a.client.Do(req) +} diff --git a/agent/config.example.ini b/agent/config.example.ini new file mode 100644 index 0000000..a10ddfd --- /dev/null +++ b/agent/config.example.ini @@ -0,0 +1,15 @@ +[agent] +# 主服务器地址(http/https,Agent 会自动转换为 WebSocket 连接) +# 如果主服务配置了url_prefix, 这里要也要加上路径 +server_url = http://192.168.1.100:8052 +# 比如 url_prefix=/taskpool +; server_url = http://192.168.1.100:8052/taskpool + +# Agent 名称(留空则使用主机名) +name = +# 注册令牌(首次注册时填写,注册成功后会自动替换为认证 Token) +token = +# 心跳间隔(秒),默认 30 +interval = 30 +# 自动更新(true/false) +auto_update = true diff --git a/agent/config.go b/agent/config.go new file mode 100644 index 0000000..bd7f69b --- /dev/null +++ b/agent/config.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + + "gopkg.in/ini.v1" +) + +// Config Agent 配置 +type Config struct { + ServerURL string + Name string + Token string + Interval int + AutoUpdate bool +} + +func loadConfigFile(path string, config *Config) error { + cfg, err := ini.Load(path) + if err != nil { + return err + } + + section := cfg.Section("agent") + if v := section.Key("server_url").String(); v != "" { + config.ServerURL = v + } + if v := section.Key("name").String(); v != "" { + config.Name = v + } + if v := section.Key("token").String(); v != "" { + config.Token = v + } + if v := section.Key("interval").String(); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + config.Interval = i + } + } + if v := section.Key("auto_update").String(); v != "" { + config.AutoUpdate = v == "true" || v == "1" + } + return nil +} + +func saveConfigFile(path string, config *Config) error { + dir := filepath.Dir(path) + if dir != "" && dir != "." { + os.MkdirAll(dir, 0755) + } + + cfg := ini.Empty() + section := cfg.Section("agent") + section.Key("server_url").SetValue(config.ServerURL) + section.Key("name").SetValue(config.Name) + section.Key("token").SetValue(config.Token) + section.Key("interval").SetValue(strconv.Itoa(config.Interval)) + if config.AutoUpdate { + section.Key("auto_update").SetValue("true") + } else { + section.Key("auto_update").SetValue("false") + } + + return cfg.SaveTo(path) +} diff --git a/agent/logger.go b/agent/logger.go new file mode 100644 index 0000000..eed76dc --- /dev/null +++ b/agent/logger.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/systime" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +// 日志实例 +var loggerInstance *zap.Logger +var log *zap.SugaredLogger + +// ANSI 颜色代码 +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorBlue = "\033[36m" + colorGray = "\033[37m" +) + +// customCore 实现 zapcore.Core 以提供与 logrus 一模一样的格式 +type customCore struct { + level zapcore.LevelEnabler + writer zapcore.WriteSyncer +} + +func (c *customCore) Enabled(l zapcore.Level) bool { + return c.level.Enabled(l) +} + +func (c *customCore) With(fields []zapcore.Field) zapcore.Core { + return c +} + +func (c *customCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(ent.Level) { + return ce.AddCore(ent, c) + } + return ce +} + +func (c *customCore) Write(ent zapcore.Entry, fields []zapcore.Field) error { + // 统一使用东八区时间 + timestamp := systime.InCST(ent.Time).Format("2006-01-02 15:04:05") + level := strings.ToUpper(ent.Level.String()) + + var levelColor string + switch ent.Level { + case zapcore.DebugLevel: + levelColor = colorGray + case zapcore.InfoLevel: + levelColor = colorBlue + case zapcore.WarnLevel: + levelColor = colorYellow + case zapcore.ErrorLevel, zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel: + levelColor = colorRed + default: + levelColor = colorBlue + } + + msg := fmt.Sprintf("[%s]%s[%s]%s %s\n", timestamp, levelColor, level, colorReset, ent.Message) + _, err := c.writer.Write([]byte(msg)) + return err +} + +func (c *customCore) Sync() error { + return c.writer.Sync() +} + +func initLogger(logFile string, fileOnly bool) { + logDir := filepath.Dir(logFile) + if logDir != "" && logDir != "." { + os.MkdirAll(logDir, 0755) + } + + lumberjackLogger := &lumberjack.Logger{ + Filename: logFile, + MaxSize: 5, + MaxBackups: 3, + MaxAge: 0, + Compress: false, + } + + var output zapcore.WriteSyncer + // fileOnly 模式下只输出到文件(daemon 模式或重启模式) + if fileOnly { + output = zapcore.AddSync(lumberjackLogger) + } else { + // 前台运行时同时输出到终端和文件 + output = zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(lumberjackLogger)) + } + + core := &customCore{ + level: zap.NewAtomicLevelAt(zap.InfoLevel), + writer: output, + } + + loggerInstance = zap.New(core) + log = loggerInstance.Sugar() +} diff --git a/agent/main.go b/agent/main.go new file mode 100644 index 0000000..37629db --- /dev/null +++ b/agent/main.go @@ -0,0 +1,437 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "reflect" + "syscall" + "time" + + internalLogger "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/systime" + "github.com/engigu/taskpool/internal/utils" +) + +const ServiceName = "taskpool-agent" +const ServiceDesc = "TaskPool Agent Service" + +// 版本信息(通过 ldflags 注入) +var ( + Version = "dev" + BuildTime = "" +) + +// 全局配置 +var ( + configFile = "config.ini" + logFile = "logs/agent.log" + dataDir = "data" +) + +func main() { + // 强制设置全局时区为东八区 + time.Local = systime.CST + exePath, _ := os.Executable() + exeDir := filepath.Dir(exePath) + os.Chdir(exeDir) + + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + cmd := os.Args[1] + + // 解析额外参数 + for i := 2; i < len(os.Args); i++ { + switch os.Args[i] { + case "-c", "--config": + if i+1 < len(os.Args) { + configFile = os.Args[i+1] + i++ + } + case "-l", "--log": + if i+1 < len(os.Args) { + logFile = os.Args[i+1] + i++ + } + case "-d", "--daemon": + isDaemon = true + case "--restart": + isRestart = true + } + } + + switch cmd { + case "start": + cmdStart() + case "run": + cmdRun() + case "stop": + cmdStop() + case "status": + cmdStatus() + case "tasks": + cmdTasks() + case "logs": + cmdLogs() + case "install": + cmdInstall() + case "uninstall": + cmdUninstall() + case "version", "-v", "--version": + fmt.Printf("TaskPool Agent v%s\n", Version) + if BuildTime != "" { + fmt.Printf("Build Time: %s\n", BuildTime) + } + case "help", "-h", "--help": + printUsage() + default: + fmt.Printf("未知命令: %s\n", cmd) + printUsage() + os.Exit(1) + } +} + +func printUsage() { + var binName = filepath.Base(os.Args[0]) + if binName == "main" || binName == "debug" { + binName = "taskpool-agent" + } + + fmt.Printf(`TaskPool Agent v%s + +用法: + %s <命令> [选项] + +命令: + start 启动 Agent(后台运行) + run 前台运行 Agent + stop 停止 Agent + status 查看运行状态 + tasks 查看已下发的任务列表 + logs 查看日志(实时跟踪) + install 安装为系统服务(开机自启) + uninstall 卸载系统服务 + version 显示版本信息 + help 显示帮助信息 + +选项: + -c, --config 配置文件路径 (默认: config.ini) + -l, --log 日志文件路径 (默认: logs/agent.log) + +示例: + %s start + %s run + %s stop + %s logs + %s start -c /etc/taskpool/config.ini + %s install + %s status + %s tasks +`, Version, binName, binName, binName, binName, binName, binName, binName, binName, binName) +} + +// daemon 模式标记 +var isDaemon = false + +// 是否从 daemon 重启(用于自动更新后重启) +var isRestart = false + +func cmdStart() { + // 检查是否已经在运行(使用文件锁) + pid := readPidFile() + if pid != 0 && isProcessRunning(pid) { + fmt.Printf("Agent 已在运行 (PID: %d)\n", pid) + return + } + + // 如果不是 daemon 子进程,则启动 daemon + if !isDaemon { + startDaemon() + return + } + + // 以下是 daemon 子进程的逻辑 + // 尝试获取文件锁 + if !tryLock() { + fmt.Println("Agent 已在运行(无法获取锁)") + return + } + defer unlock() + + initLogger(logFile, true) + internalLogger.SetOutput(loggerInstance) + + config := &Config{Interval: 30} + if err := loadConfigFile(configFile, config); err != nil { + if !os.IsNotExist(err) { + log.Warnf("加载配置文件失败: %v", err) + } + } + + // 从环境变量加载 + if v := os.Getenv("AGENT_SERVER"); v != "" { + config.ServerURL = v + } + if v := os.Getenv("AGENT_NAME"); v != "" { + config.Name = v + } + + if config.ServerURL == "" { + log.Fatal("请在配置文件中设置 server_url") + } + if config.Name == "" { + hostname, _ := os.Hostname() + config.Name = hostname + } + + log.Infof("TaskPool Agent Version: %s", Version) + if BuildTime != "" { + log.Infof("构建时间: %s", BuildTime) + } + log.Infof("服务器: %s", config.ServerURL) + log.Infof("名称: %s", config.Name) + + writePidFile() + + agent := NewAgent(config, configFile) + if err := agent.Start(); err != nil { + log.Fatalf("启动失败: %v", err) + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("正在停止...") + agent.Stop() + removePidFile() +} + +// cmdRun 前台运行 +func cmdRun() { + // 检查是否已经在运行(重启模式下跳过检查) + if !isRestart { + pid := readPidFile() + if pid != 0 && isProcessRunning(pid) { + fmt.Printf("Agent 已在运行 (PID: %d)\n", pid) + return + } + } + + // 尝试获取文件锁 + if !tryLock() { + fmt.Println("Agent 已在运行(无法获取锁)") + return + } + defer unlock() + + // 前台模式始终输出到终端+文件 + initLogger(logFile, false) + internalLogger.SetOutput(loggerInstance) + + config := &Config{Interval: 30} + if err := loadConfigFile(configFile, config); err != nil { + if !os.IsNotExist(err) { + log.Warnf("加载配置文件失败: %v", err) + } + } + + if v := os.Getenv("AGENT_SERVER"); v != "" { + config.ServerURL = v + } + if v := os.Getenv("AGENT_NAME"); v != "" { + config.Name = v + } + + if config.ServerURL == "" { + log.Fatal("请在配置文件中设置 server_url") + } + if config.Name == "" { + hostname, _ := os.Hostname() + config.Name = hostname + } + + log.Infof("TaskPool Agent Version: %s", Version) + if BuildTime != "" { + log.Infof("构建时间: %s", BuildTime) + } + log.Infof("服务器: %s", config.ServerURL) + log.Infof("名称: %s", config.Name) + + writePidFile() + + agent := NewAgent(config, configFile) + if err := agent.Start(); err != nil { + log.Fatalf("启动失败: %v", err) + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("正在停止...") + agent.Stop() + removePidFile() +} + +func startDaemon() { + exePath, err := os.Executable() + if err != nil { + fmt.Printf("获取可执行文件路径失败: %v\n", err) + return + } + + // 构建子进程参数,添加 --daemon 标记 + args := []string{"start", "--daemon"} + for i := 2; i < len(os.Args); i++ { + if os.Args[i] != "--daemon" && os.Args[i] != "-d" { + args = append(args, os.Args[i]) + } + } + + // 打开 /dev/null 用于丢弃输出(日志由 logger 写入文件) + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + fmt.Printf("打开 /dev/null 失败: %v\n", err) + return + } + + // 启动子进程 + cmd := &exec.Cmd{ + Path: exePath, + Args: append([]string{exePath}, args...), + Dir: filepath.Dir(exePath), + Stdout: devNull, + Stderr: devNull, + } + + // 设置进程组,使子进程独立运行 (跨平台兼容写法) + attr := &syscall.SysProcAttr{} + if field := reflect.ValueOf(attr).Elem().FieldByName("Setsid"); field.IsValid() { + field.SetBool(true) + } + cmd.SysProcAttr = attr + + if err := cmd.Start(); err != nil { + fmt.Printf("启动失败: %v\n", err) + devNull.Close() + return + } + + devNull.Close() + fmt.Printf("Agent 已启动 (PID: %d)\n", cmd.Process.Pid) + fmt.Printf("日志文件: %s\n", logFile) +} + +func cmdTasks() { + config := &Config{Interval: 30} + if err := loadConfigFile(configFile, config); err != nil { + fmt.Printf("加载配置文件失败: %v\n", err) + return + } + + if config.ServerURL == "" { + fmt.Println("错误: 缺少服务器地址,请在配置文件中设置 server_url") + return + } + + if config.Token == "" { + fmt.Println("错误: 缺少令牌,请在配置文件中设置 token") + return + } + + agent := &Agent{ + config: config, + machineID: utils.GenerateMachineID(), + client: &http.Client{Timeout: 30 * time.Second}, + } + + resp, err := agent.doRequest("GET", "/api/agent/tasks", nil) + if err != nil { + fmt.Printf("获取任务列表失败: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + fmt.Printf("获取任务列表失败 (HTTP %d): %s\n", resp.StatusCode, string(body)) + return + } + + // 解析服务端响应(包含 code/msg/data 包装) + var apiResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + AgentID string `json:"agent_id"` + Tasks []AgentTask `json:"tasks"` + } `json:"data"` + } + if err := json.Unmarshal(body, &apiResp); err != nil { + fmt.Printf("解析响应失败: %v\n", err) + return + } + + if apiResp.Code != 200 { + fmt.Printf("获取任务列表失败: %s\n", apiResp.Msg) + return + } + + tasks := apiResp.Data.Tasks + if len(tasks) == 0 { + fmt.Println("当前没有下发的任务") + return + } + + fmt.Printf("共 %d 个任务:\n\n", len(tasks)) + for i, task := range tasks { + fmt.Printf("[%d] ID: %s\n", i+1, task.ID) + fmt.Printf(" 名称: %s\n", task.Name) + fmt.Printf(" Cron: %s\n", task.Schedule) + fmt.Printf(" 命令: %s\n", task.Command) + if task.WorkDir != "" { + fmt.Printf(" 工作目录: %s\n", task.WorkDir) + } + fmt.Printf(" 启用: %v\n", task.Enabled) + fmt.Println() + } +} + +func cmdLogs() { + // 检查日志文件是否存在 + if _, err := os.Stat(logFile); os.IsNotExist(err) { + fmt.Printf("日志文件不存在: %s\n", logFile) + return + } + + fmt.Printf("日志文件: %s\n", logFile) + fmt.Println("按 Ctrl+C 退出") + + // 使用 tail -f 实时跟踪日志 + cmd := exec.Command("tail", "-f", "-n", "50", logFile) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // 处理中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-quit + if cmd.Process != nil { + cmd.Process.Kill() + } + }() + + cmd.Run() +} diff --git a/agent/service.go b/agent/service.go new file mode 100644 index 0000000..fb76a6a --- /dev/null +++ b/agent/service.go @@ -0,0 +1,241 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "syscall" + + "github.com/gofrs/flock" +) + +// ========== PID 文件管理 ========== + +var fileLock *flock.Flock + +func getPidFile() string { + return filepath.Join(dataDir, "agent.pid") +} + +func getLockFile() string { + return filepath.Join(dataDir, "agent.lock") +} + +// tryLock 尝试获取文件锁,确保只有一个实例运行 +func tryLock() bool { + os.MkdirAll(dataDir, 0755) + + fileLock = flock.New(getLockFile()) + locked, err := fileLock.TryLock() + if err != nil || !locked { + fileLock = nil + return false + } + return true +} + +// unlock 释放文件锁 +func unlock() { + if fileLock != nil { + fileLock.Unlock() + fileLock = nil + os.Remove(getLockFile()) + } +} + +func writePidFile() { + os.MkdirAll(dataDir, 0755) + pidFile := getPidFile() + os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0644) +} + +func readPidFile() int { + pidFile := getPidFile() + data, err := os.ReadFile(pidFile) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(string(data)) + return pid +} + +func removePidFile() { + os.Remove(getPidFile()) +} + +// ========== 命令实现 ========== + +func cmdStop() { + pid := readPidFile() + if pid == 0 { + fmt.Println("Agent 未运行") + return + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Printf("找不到进程 %d\n", pid) + removePidFile() + return + } + + if runtime.GOOS == "windows" { + err = process.Kill() + } else { + err = process.Signal(syscall.SIGTERM) + } + + if err != nil { + fmt.Printf("停止失败: %v\n", err) + return + } + + fmt.Println("Agent 已停止") + removePidFile() +} + +func cmdStatus() { + pid := readPidFile() + if pid == 0 { + fmt.Println("状态: 未运行") + return + } + + if !isProcessRunning(pid) { + fmt.Println("状态: 未运行") + removePidFile() + return + } + + fmt.Printf("状态: 运行中 (PID: %d)\n", pid) +} + +func isProcessRunning(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // Unix 系统发送信号 0 检查进程 + if runtime.GOOS != "windows" { + err = process.Signal(syscall.Signal(0)) + return err == nil + } + + // Windows 下 FindProcess 成功即表示进程存在 + return true +} + +func cmdInstall() { + exePath, _ := os.Executable() + exeDir := filepath.Dir(exePath) + + if runtime.GOOS == "windows" { + installWindows(exePath, exeDir) + } else { + installLinux(exePath, exeDir) + } +} + +func cmdUninstall() { + if runtime.GOOS == "windows" { + uninstallWindows() + } else { + uninstallLinux() + } +} + +// ========== Linux systemd ========== + +func installLinux(exePath, exeDir string) { + serviceContent := fmt.Sprintf(`[Unit] +Description=%s +After=network.target + +[Service] +Type=simple +WorkingDirectory=%s +ExecStart=%s run +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +`, ServiceDesc, exeDir, exePath) + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", ServiceName) + if err := os.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil { + fmt.Printf("创建服务文件失败: %v\n", err) + fmt.Println("请使用 sudo 运行") + return + } + + // 重载 systemd + exec.Command("systemctl", "daemon-reload").Run() + exec.Command("systemctl", "enable", ServiceName).Run() + + fmt.Printf("服务已安装: %s\n", servicePath) + fmt.Println("使用以下命令管理服务:") + fmt.Printf(" 启动: sudo systemctl start %s\n", ServiceName) + fmt.Printf(" 停止: sudo systemctl stop %s\n", ServiceName) + fmt.Printf(" 状态: sudo systemctl status %s\n", ServiceName) +} + +func uninstallLinux() { + // 停止服务 + exec.Command("systemctl", "stop", ServiceName).Run() + exec.Command("systemctl", "disable", ServiceName).Run() + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", ServiceName) + if err := os.Remove(servicePath); err != nil { + fmt.Printf("删除服务文件失败: %v\n", err) + fmt.Println("请使用 sudo 运行") + return + } + + exec.Command("systemctl", "daemon-reload").Run() + fmt.Println("服务已卸载") +} + +// ========== Windows 服务 ========== + +func installWindows(exePath, exeDir string) { + // 使用 sc.exe 创建服务 + cmd := exec.Command("sc", "create", ServiceName, + "binPath=", fmt.Sprintf(`"%s" run`, exePath), + "start=", "auto", + "DisplayName=", ServiceDesc) + + if err := cmd.Run(); err != nil { + fmt.Printf("创建服务失败: %v\n", err) + fmt.Println("请以管理员身份运行") + return + } + + // 设置服务描述 + exec.Command("sc", "description", ServiceName, ServiceDesc).Run() + + fmt.Println("服务已安装") + fmt.Println("使用以下命令管理服务:") + fmt.Printf(" 启动: sc start %s\n", ServiceName) + fmt.Printf(" 停止: sc stop %s\n", ServiceName) + fmt.Printf(" 状态: sc query %s\n", ServiceName) +} + +func uninstallWindows() { + // 停止服务 + exec.Command("sc", "stop", ServiceName).Run() + + // 删除服务 + cmd := exec.Command("sc", "delete", ServiceName) + if err := cmd.Run(); err != nil { + fmt.Printf("删除服务失败: %v\n", err) + fmt.Println("请以管理员身份运行") + return + } + + fmt.Println("服务已卸载") +} diff --git a/agent/update.go b/agent/update.go new file mode 100644 index 0000000..9e7f81b --- /dev/null +++ b/agent/update.go @@ -0,0 +1,161 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" +) + +// selfUpdate 自动更新 +func (a *Agent) selfUpdate() { + // 获取当前可执行文件路径 + exePath, err := os.Executable() + if err != nil { + log.Errorf("获取可执行文件路径失败: %v", err) + return + } + exePath, _ = filepath.Abs(exePath) + + // 下载新版本 tar.gz + downloadURL := a.config.ServerURL + "/api/agent/download?os=" + runtime.GOOS + "&arch=" + runtime.GOARCH + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + log.Errorf("创建下载请求失败: %v", err) + return + } + req.Header.Set("Authorization", "Bearer "+a.config.Token) + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + log.Errorf("下载新版本失败: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Errorf("下载新版本失败: HTTP %d", resp.StatusCode) + return + } + + // 读取 tar.gz 内容 + gzReader, err := gzip.NewReader(resp.Body) + if err != nil { + log.Errorf("解压 gzip 失败: %v", err) + return + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + // 解压并找到二进制文件 + var newBinary []byte + binaryName := "taskpool-agent" + if runtime.GOOS == "windows" { + binaryName = "taskpool-agent.exe" + } + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + log.Errorf("读取 tar 失败: %v", err) + return + } + + if header.Typeflag == tar.TypeReg && header.Name == binaryName { + newBinary, err = io.ReadAll(tarReader) + if err != nil { + log.Errorf("读取二进制文件失败: %v", err) + return + } + break + } + } + + if newBinary == nil { + log.Errorf("tar.gz 中未找到 %s", binaryName) + return + } + + // 保存到临时文件(放到 data 目录) + os.MkdirAll(dataDir, 0755) + tmpFile := filepath.Join(dataDir, binaryName+".new") + if err := os.WriteFile(tmpFile, newBinary, 0755); err != nil { + log.Errorf("保存新版本失败: %v", err) + return + } + + // 计算基础路径(去掉所有 .bak 后缀) + basePath := exePath + for strings.HasSuffix(basePath, ".bak") { + basePath = strings.TrimSuffix(basePath, ".bak") + } + backupFile := basePath + ".bak" + + // 如果当前运行的就是 .bak 文件,直接删除它(更新后会用新版本) + // 否则需要备份当前文件 + if exePath != backupFile { + os.Remove(backupFile) + if err := os.Rename(exePath, backupFile); err != nil { + log.Errorf("备份旧版本失败: %v", err) + os.Remove(tmpFile) + return + } + } + + // 替换为新版本(放到 basePath,即不带 .bak 的路径) + if err := os.Rename(tmpFile, basePath); err != nil { + log.Errorf("替换新版本失败: %v", err) + if exePath != backupFile { + os.Rename(backupFile, exePath) // 恢复旧版本 + } + return + } + + // 如果之前运行的是 .bak 文件,现在可以删除它了 + if exePath == backupFile { + os.Remove(exePath) + } + + log.Info("更新完成,正在重启...") + + // 重启服务 + a.restart() +} + +// restart 重启服务 +func (a *Agent) restart() { + exePath, _ := os.Executable() + + // 计算基础路径(去掉所有 .bak 后缀),确保启动的是正确的可执行文件 + basePath := exePath + for strings.HasSuffix(basePath, ".bak") { + basePath = strings.TrimSuffix(basePath, ".bak") + } + + // 删除 PID 文件,避免新进程检测到旧 PID 而拒绝启动 + removePidFile() + + if runtime.GOOS == "windows" { + // Windows: 启动新进程后退出 + cmd := exec.Command(basePath, "start") + cmd.Start() + os.Exit(0) + } else { + // Linux/macOS: 使用 exec 替换当前进程,直接运行(不需要 daemon) + // 因为 syscall.Exec 会替换当前进程,当前进程本身就是 daemon + // --restart 标记告诉新进程这是重启,只输出到文件 + syscall.Exec(basePath, []string{basePath, "run", "--restart"}, os.Environ()) + } +} diff --git a/builtin/nodejs/env.js b/builtin/nodejs/env.js new file mode 100644 index 0000000..1dada5f --- /dev/null +++ b/builtin/nodejs/env.js @@ -0,0 +1,187 @@ +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +/** + * 内部 API 请求辅助函数 + */ +function request(urlStr, method = 'GET', data = null) { + const token = process.env.BHPKG_OPENAPI_TOKEN || process.env.OPENAPI_TOKEN || process.env.BHPKG_NOTIFY_TOKEN; + if (!token) { + throw new Error(`没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 env 函数。请在任务池的任务设置中配置这些 Key。`); + } + + const parsedUrl = new URL(urlStr); + const protocol = parsedUrl.protocol === 'https:' ? https : http; + + let payload = ''; + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; + + if (data !== null) { + payload = JSON.stringify(data); + headers['Content-Length'] = Buffer.byteLength(payload); + } + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + (parsedUrl.search || ''), + method: method, + headers: headers + }; + + return new Promise((resolve, reject) => { + const req = protocol.request(options, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsed = body ? JSON.parse(body) : {}; + if (parsed && typeof parsed === 'object' && parsed.code !== undefined && parsed.code !== 200) { + reject(new Error(`请求失败 [${parsed.code}]: ${parsed.msg || parsed.message || '未知错误'}`)); + } else { + resolve(parsed); + } + } catch (e) { + resolve(body); + } + } else { + let errMsg = body; + try { + const parsed = JSON.parse(body); + errMsg = parsed.msg || parsed.message || body; + } catch(e) {} + reject(new Error(`请求失败 [${res.statusCode}]: ${errMsg}`)); + } + }); + }); + + req.on('error', (e) => reject(e)); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +function getEnvsUrl() { + const url = process.env.BHPKG_OPENAPI_URL || process.env.OPENAPI_URL; + if (url) return url; + + const notifyUrl = process.env.BHPKG_NOTIFY_URL || 'http://localhost:8052/api/v1/notify/send'; + const targets = ['/api/v1/notify/send/', '/api/v1/notify/send', '/api/v1/notify/', '/api/v1/notify']; + for (const target of targets) { + if (notifyUrl.includes(target)) { + return notifyUrl.replace(target, '/open2api/v1/env'); + } + } + return 'http://localhost:8052/open2api/v1/env'; +} + +/** + * 获取所有的环境变量列表 + */ +async function getEnvs() { + const url = `${getEnvsUrl()}/all`; + const res = await request(url, 'GET'); + return res.data || []; +} + +/** + * 根据变量名获取环境变量,不存在则返回 null + */ +async function getEnv(name) { + const envs = await getEnvs(); + for (const env of envs) { + if (env.name === name) { + return env; + } + } + return null; +} + +/** + * 批量添加环境变量 + */ +async function addEnvs(envsList) { + const url = getEnvsUrl(); + const addedEnvs = []; + for (const env of envsList) { + if (!env.name || !env.value) { + throw new Error("环境变量必须包含 'name' 和 'value'"); + } + const res = await request(url, 'POST', env); + if (res.data) { + addedEnvs.push(res.data); + } + } + return addedEnvs; +} + +/** + * 添加单个环境变量 + */ +async function addEnv(name, value, remark = "", type = "normal", hidden = true, enabled = true) { + const url = getEnvsUrl(); + const payload = { + name, + value, + remark, + type, + hidden, + enabled + }; + const res = await request(url, 'POST', payload); + return res.data; +} + +/** + * 根据 ID 更新环境变量 + */ +async function updateEnv(id, name, value, remark = null, type = null, hidden = null, enabled = null) { + const url = `${getEnvsUrl()}/${id}`; + const payload = {}; + if (name !== null) payload.name = name; + if (value !== null) payload.value = value; + if (remark !== null) payload.remark = remark; + if (type !== null) payload.type = type; + if (hidden !== null) payload.hidden = hidden; + if (enabled !== null) payload.enabled = enabled; + + const res = await request(url, 'PUT', payload); + return res.data; +} + +/** + * 批量删除环境变量 + */ +async function deleteEnvs(ids) { + for (const id of ids) { + await deleteEnv(id); + } + return true; +} + +/** + * 根据 ID 删除指定环境变量 + */ +async function deleteEnv(id) { + const url = `${getEnvsUrl()}/${id}`; + await request(url, 'DELETE'); + return true; +} + +module.exports = { + getEnvs, + getEnv, + addEnvs, + addEnv, + updateEnv, + deleteEnvs, + deleteEnv +}; diff --git a/builtin/nodejs/index.js b/builtin/nodejs/index.js new file mode 100644 index 0000000..7cbb6ec --- /dev/null +++ b/builtin/nodejs/index.js @@ -0,0 +1,37 @@ +const { notify } = require('./notify'); +const { + getEnvs, + getEnv, + addEnvs, + addEnv, + updateEnv, + deleteEnvs, + deleteEnv +} = require('./env'); +const { + getTasks, + getTask, + updateTask, + deleteTask, + executeTask, + stopTask, + getLastResults +} = require('./task'); + +module.exports = { + notify, + getEnvs, + getEnv, + addEnvs, + addEnv, + updateEnv, + deleteEnvs, + deleteEnv, + getTasks, + getTask, + updateTask, + deleteTask, + executeTask, + stopTask, + getLastResults +}; diff --git a/builtin/nodejs/notify.js b/builtin/nodejs/notify.js new file mode 100644 index 0000000..d839a98 --- /dev/null +++ b/builtin/nodejs/notify.js @@ -0,0 +1,51 @@ +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +/** + * 发送通知的辅助函数 (仅使用 Node.js 标准库) + */ +function notify(title, text, channelId) { + const token = process.env.BHPKG_NOTIFY_TOKEN; + const channel = process.env.BHPKG_NOTIFY_CHANNEL; + + if (!token || !channel) { + const missing = []; + if (!token) missing.push("BHPKG_NOTIFY_TOKEN"); + if (!channel) missing.push("BHPKG_NOTIFY_CHANNEL"); + throw new Error(`没有正确配置或缺少 ${missing.join(" 和 ")} 环境变量以使用 notify 函数。请在任务池的任务设置中配置这些 Key。`); + } + + const notifyUrl = process.env.BHPKG_NOTIFY_URL || 'http://localhost:8052/api/v1/notify/send'; + const cid = channelId || channel; + + if (!notifyUrl || !token || !cid) return; + + const parsedUrl = new URL(notifyUrl); + const protocol = parsedUrl.protocol === 'https:' ? https : http; + + const data = JSON.stringify({ + channel_id: cid, + title: title || '系统通知', + text: text + }); + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + (parsedUrl.search || ''), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'notify-token': token, + 'Content-Length': Buffer.byteLength(data) + } + }; + + const req = protocol.request(options); + req.on('error', (e) => {}); + req.write(data); + req.end(); +} + +module.exports = { notify }; diff --git a/builtin/nodejs/package.json b/builtin/nodejs/package.json new file mode 100644 index 0000000..d8fba11 --- /dev/null +++ b/builtin/nodejs/package.json @@ -0,0 +1,7 @@ +{ + "name": "taskpool", + "version": "1.0.0", + "description": "TaskPool internal helper for Node.js", + "main": "index.js", + "license": "MIT" +} diff --git a/builtin/nodejs/task.js b/builtin/nodejs/task.js new file mode 100644 index 0000000..e8da831 --- /dev/null +++ b/builtin/nodejs/task.js @@ -0,0 +1,175 @@ +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +/** + * 内部 API 请求辅助函数 + */ +function request(urlStr, method = 'GET', data = null) { + const token = process.env.BHPKG_OPENAPI_TOKEN || process.env.OPENAPI_TOKEN || process.env.BHPKG_NOTIFY_TOKEN; + if (!token) { + throw new Error(`没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 task 函数。请在任务池的任务设置中配置这些 Key。`); + } + + const parsedUrl = new URL(urlStr); + const protocol = parsedUrl.protocol === 'https:' ? https : http; + + let payload = ''; + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; + + if (data !== null) { + payload = JSON.stringify(data); + headers['Content-Length'] = Buffer.byteLength(payload); + } + + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + (parsedUrl.search || ''), + method: method, + headers: headers + }; + + return new Promise((resolve, reject) => { + const req = protocol.request(options, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => body += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const parsed = body ? JSON.parse(body) : {}; + if (parsed && typeof parsed === 'object' && parsed.code !== undefined && parsed.code !== 200) { + reject(new Error(`请求失败 [${parsed.code}]: ${parsed.msg || parsed.message || '未知错误'}`)); + } else { + resolve(parsed); + } + } catch (e) { + resolve(body); + } + } else { + let errMsg = body; + try { + const parsed = JSON.parse(body); + errMsg = parsed.msg || parsed.message || body; + } catch(e) {} + reject(new Error(`请求失败 [${res.statusCode}]: ${errMsg}`)); + } + }); + }); + + req.on('error', (e) => reject(e)); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +function getBaseUrl() { + const url = process.env.BHPKG_OPENAPI_URL || process.env.OPENAPI_URL; + if (url) { + if (url.endsWith('/env')) return url.slice(0, -4); + if (url.endsWith('/env/')) return url.slice(0, -5); + return url; + } + + const notifyUrl = process.env.BHPKG_NOTIFY_URL || 'http://localhost:8052/api/v1/notify/send'; + const targets = ['/api/v1/notify/send/', '/api/v1/notify/send', '/api/v1/notify/', '/api/v1/notify']; + for (const target of targets) { + if (notifyUrl.includes(target)) { + return notifyUrl.replace(target, '/open2api/v1'); + } + } + return 'http://localhost:8052/open2api/v1'; +} + +/** + * 获取全部任务列表 + */ +async function getTasks() { + const url = `${getBaseUrl()}/tasks`; + const res = await request(url, 'GET'); + return res.data || []; +} + +/** + * 根据 ID 获取单个任务信息 + */ +async function getTask(id) { + const url = `${getBaseUrl()}/tasks/${id}`; + const res = await request(url, 'GET'); + return res.data; +} + +/** + * 根据 ID 更新指定任务 + */ +async function updateTask(id, name, command, remark, pin_type, trigger_type, schedule, timeout, work_dir, retry_count, retry_interval, random_range, enabled) { + const url = `${getBaseUrl()}/tasks/${id}`; + const payload = {}; + if (name !== undefined) payload.name = name; + if (command !== undefined) payload.command = command; + if (remark !== undefined) payload.remark = remark; + if (pin_type !== undefined) payload.pin_type = pin_type; + if (trigger_type !== undefined) payload.trigger_type = trigger_type; + if (schedule !== undefined) payload.schedule = schedule; + if (timeout !== undefined) payload.timeout = timeout; + if (work_dir !== undefined) payload.work_dir = work_dir; + if (retry_count !== undefined) payload.retry_count = retry_count; + if (retry_interval !== undefined) payload.retry_interval = retry_interval; + if (random_range !== undefined) payload.random_range = random_range; + if (enabled !== undefined) payload.enabled = enabled; + + const res = await request(url, 'PUT', payload); + return res.data; +} + +/** + * 根据 ID 删除任务 + */ +async function deleteTask(id) { + const url = `${getBaseUrl()}/tasks/${id}`; + await request(url, 'DELETE'); + return true; +} + +/** + * 触发运行指定任务 + */ +async function executeTask(id) { + const url = `${getBaseUrl()}/execute/task/${id}`; + const res = await request(url, 'POST'); + return res.data; +} + +/** + * 根据日志 ID 停止正在运行的任务 + */ +async function stopTask(logId) { + const url = `${getBaseUrl()}/tasks/stop/${logId}`; + const res = await request(url, 'POST'); + return res.data; +} + +/** + * 获取最近的任务执行结果列表 + */ +async function getLastResults() { + const url = `${getBaseUrl()}/execute/results`; + const res = await request(url, 'GET'); + return res.data || []; +} + +module.exports = { + getTasks, + getTask, + updateTask, + deleteTask, + executeTask, + stopTask, + getLastResults +}; diff --git a/builtin/python/setup.py b/builtin/python/setup.py new file mode 100644 index 0000000..a12707d --- /dev/null +++ b/builtin/python/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup, find_packages + +setup( + name='taskpool', + version='1.0.0', + description='TaskPool internal helper for Python', + packages=['taskpool'], + package_dir={'taskpool': 'taskpool'}, + python_requires='>=3.6', +) diff --git a/builtin/python/taskpool/__init__.py b/builtin/python/taskpool/__init__.py new file mode 100644 index 0000000..c5b9f89 --- /dev/null +++ b/builtin/python/taskpool/__init__.py @@ -0,0 +1,56 @@ +import os +from .notify import notify as _notify +from .env import ( + get_envs, + get_env, + add_envs, + add_env, + update_env, + delete_envs, + delete_env +) +from .task import ( + get_tasks, + get_task, + update_task, + delete_task, + execute_task, + stop_task, + get_last_results +) + +def notify(title, text): + """ + 发送内建通知。 + 会在调用时校验环境变量:BHPKG_NOTIFY_TOKEN, BHPKG_NOTIFY_CHANNEL + """ + _TOKEN = os.environ.get("BHPKG_NOTIFY_TOKEN") + _CHANNEL = os.environ.get("BHPKG_NOTIFY_CHANNEL") + + if not _TOKEN or not _CHANNEL: + missing = [] + if not _TOKEN: missing.append("BHPKG_NOTIFY_TOKEN") + if not _CHANNEL: missing.append("BHPKG_NOTIFY_CHANNEL") + + error_msg = f"缺少必要的环境变量以使用 taskpool 模块: {', '.join(missing)}。请在任务池的任务设置中配置指定的 Key。" + raise RuntimeError(error_msg) + + return _notify(title, text) + +__all__ = [ + 'notify', + 'get_envs', + 'get_env', + 'add_envs', + 'add_env', + 'update_env', + 'delete_envs', + 'delete_env', + 'get_tasks', + 'get_task', + 'update_task', + 'delete_task', + 'execute_task', + 'stop_task', + 'get_last_results' +] diff --git a/builtin/python/taskpool/env.py b/builtin/python/taskpool/env.py new file mode 100644 index 0000000..897c1ea --- /dev/null +++ b/builtin/python/taskpool/env.py @@ -0,0 +1,133 @@ +import os +import json +import urllib.request +import urllib.error + +def _get_headers(): + token = os.environ.get("BHPKG_OPENAPI_TOKEN") or os.environ.get("OPENAPI_TOKEN") or os.environ.get("BHPKG_NOTIFY_TOKEN") + if not token: + raise RuntimeError("没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 env 函数。请在任务池的任务设置中配置这些 Key。") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}" + } + +def _get_envs_url(): + url = os.environ.get("BHPKG_OPENAPI_URL") or os.environ.get("OPENAPI_URL") + if url: + return url + + notify_url = os.environ.get("BHPKG_NOTIFY_URL", "http://localhost:8052/api/v1/notify/send") + for target in ["/api/v1/notify/send/", "/api/v1/notify/send", "/api/v1/notify/", "/api/v1/notify"]: + if target in notify_url: + return notify_url.replace(target, "/open2api/v1/env") + + return "http://localhost:8052/open2api/v1/env" + +def _request(url, method="GET", data=None): + headers = _get_headers() + payload = None + if data is not None: + payload = json.dumps(data).encode("utf-8") + + req = urllib.request.Request(url, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + if not body: + return {} + parsed = json.loads(body) + if isinstance(parsed, dict) and parsed.get("code") is not None and parsed.get("code") != 200: + msg = parsed.get("msg") or parsed.get("message") or "未知错误" + raise RuntimeError(f"请求失败 [{parsed.get('code')}]: {msg}") + return parsed + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8") + try: + err_json = json.loads(err_body) + msg = err_json.get("msg") or err_json.get("message") or err_body + except Exception: + msg = err_body + raise RuntimeError(f"请求失败 [{e.code}]: {msg}") + except Exception as e: + raise RuntimeError(f"请求发生异常: {e}") + +def get_envs(): + """ + 获取所有的环境变量列表。 + """ + url = f"{_get_envs_url()}/all" + res = _request(url, "GET") + return res.get("data", []) + +def get_env(name): + """ + 根据变量名获取环境变量。如果不存在则返回 None。 + """ + envs = get_envs() + for env in envs: + if env.get("name") == name: + return env + return None + +def add_envs(envs_list): + """ + 批量添加环境变量。 + envs_list: 包含环境变量字典的列表,如 [{"name": "KEY", "value": "VAL", "remark": "备注"}] + """ + url = _get_envs_url() + added_envs = [] + for env in envs_list: + if "name" not in env or "value" not in env: + raise ValueError("环境变量必须包含 'name' 和 'value'") + res = _request(url, "POST", env) + if "data" in res: + added_envs.append(res["data"]) + return added_envs + +def add_env(name, value, remark="", type="normal", hidden=True, enabled=True): + """ + 添加单个环境变量。 + """ + url = _get_envs_url() + payload = { + "name": name, + "value": value, + "remark": remark, + "type": type, + "hidden": hidden, + "enabled": enabled + } + res = _request(url, "POST", payload) + return res.get("data") + +def update_env(id, name, value, remark=None, type=None, hidden=None, enabled=None): + """ + 根据 ID 更新环境变量。 + """ + url = f"{_get_envs_url()}/{id}" + payload = {} + if name is not None: payload["name"] = name + if value is not None: payload["value"] = value + if remark is not None: payload["remark"] = remark + if type is not None: payload["type"] = type + if hidden is not None: payload["hidden"] = hidden + if enabled is not None: payload["enabled"] = enabled + + res = _request(url, "PUT", payload) + return res.get("data") + +def delete_envs(ids): + """ + 批量删除环境变量。 + """ + for fid in ids: + delete_env(fid) + +def delete_env(id): + """ + 根据 ID 删除指定的环境变量。 + """ + url = f"{_get_envs_url()}/{id}" + _request(url, "DELETE") + return True diff --git a/builtin/python/taskpool/notify.py b/builtin/python/taskpool/notify.py new file mode 100644 index 0000000..bd96b77 --- /dev/null +++ b/builtin/python/taskpool/notify.py @@ -0,0 +1,32 @@ +import os +import json +import urllib.request + +def notify(title, text, channel_id=None): + """ + 发送内建通知。 + """ + token = os.environ.get("BHPKG_NOTIFY_TOKEN") + url = os.environ.get("BHPKG_NOTIFY_URL", "http://localhost:8052/api/v1/notify/send") + default_channel = os.environ.get("BHPKG_NOTIFY_CHANNEL") + + cid = channel_id or default_channel + + if not url or not token or not cid: + return + + payload = { + "channel_id": cid, + "title": title, + "text": text + } + + data = json.dumps(payload).encode('utf-8') + req = urllib.request.Request(url, data=data, method='POST') + req.add_header('Content-Type', 'application/json') + req.add_header('notify-token', token) + + + with urllib.request.urlopen(req) as resp: + return resp.read().decode('utf-8') + diff --git a/builtin/python/taskpool/task.py b/builtin/python/taskpool/task.py new file mode 100644 index 0000000..8ef55ab --- /dev/null +++ b/builtin/python/taskpool/task.py @@ -0,0 +1,128 @@ +import os +import json +import urllib.request +import urllib.error + +def _get_headers(): + token = os.environ.get("BHPKG_OPENAPI_TOKEN") or os.environ.get("OPENAPI_TOKEN") or os.environ.get("BHPKG_NOTIFY_TOKEN") + if not token: + raise RuntimeError("没有正确配置或缺少 BHPKG_OPENAPI_TOKEN 环境变量以使用 task 函数。请在任务池的任务设置中配置这些 Key。") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}" + } + +def _get_base_url(): + url = os.environ.get("BHPKG_OPENAPI_URL") or os.environ.get("OPENAPI_URL") + if url: + # If openapi_url ends with /env, replace it with nothing or use base + if url.endswith("/env"): + return url[:-4] + elif url.endswith("/env/"): + return url[:-5] + return url + + notify_url = os.environ.get("BHPKG_NOTIFY_URL", "http://localhost:8052/api/v1/notify/send") + for target in ["/api/v1/notify/send/", "/api/v1/notify/send", "/api/v1/notify/", "/api/v1/notify"]: + if target in notify_url: + return notify_url.replace(target, "/open2api/v1") + + return "http://localhost:8052/open2api/v1" + +def _request(url, method="GET", data=None): + headers = _get_headers() + payload = None + if data is not None: + payload = json.dumps(data).encode("utf-8") + + req = urllib.request.Request(url, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + if not body: + return {} + parsed = json.loads(body) + if isinstance(parsed, dict) and parsed.get("code") is not None and parsed.get("code") != 200: + msg = parsed.get("msg") or parsed.get("message") or "未知错误" + raise RuntimeError(f"请求失败 [{parsed.get('code')}]: {msg}") + return parsed + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8") + try: + err_json = json.loads(err_body) + msg = err_json.get("msg") or err_json.get("message") or err_body + except Exception: + msg = err_body + raise RuntimeError(f"请求失败 [{e.code}]: {msg}") + except Exception as e: + raise RuntimeError(f"请求发生异常: {e}") + +def get_tasks(): + """ + 获取全部任务列表。 + """ + url = f"{_get_base_url()}/tasks" + res = _request(url, "GET") + return res.get("data", []) + +def get_task(id): + """ + 根据 ID 获取单个任务的详细信息。 + """ + url = f"{_get_base_url()}/tasks/{id}" + res = _request(url, "GET") + return res.get("data") + +def update_task(id, name=None, command=None, remark=None, pin_type=None, trigger_type=None, schedule=None, timeout=None, work_dir=None, retry_count=None, retry_interval=None, random_range=None, enabled=None): + """ + 根据 ID 更新任务。 + """ + url = f"{_get_base_url()}/tasks/{id}" + payload = {} + if name is not None: payload["name"] = name + if command is not None: payload["command"] = command + if remark is not None: payload["remark"] = remark + if pin_type is not None: payload["pin_type"] = pin_type + if trigger_type is not None: payload["trigger_type"] = trigger_type + if schedule is not None: payload["schedule"] = schedule + if timeout is not None: payload["timeout"] = timeout + if work_dir is not None: payload["work_dir"] = work_dir + if retry_count is not None: payload["retry_count"] = retry_count + if retry_interval is not None: payload["retry_interval"] = retry_interval + if random_range is not None: payload["random_range"] = random_range + if enabled is not None: payload["enabled"] = enabled + + res = _request(url, "PUT", payload) + return res.get("data") + +def delete_task(id): + """ + 根据 ID 删除指定任务。 + """ + url = f"{_get_base_url()}/tasks/{id}" + _request(url, "DELETE") + return True + +def execute_task(id): + """ + 触发执行特定任务。 + """ + url = f"{_get_base_url()}/execute/task/{id}" + res = _request(url, "POST") + return res.get("data") + +def stop_task(log_id): + """ + 根据日志 ID 停止正在运行的任务。 + """ + url = f"{_get_base_url()}/tasks/stop/{log_id}" + res = _request(url, "POST") + return res.get("data") + +def get_last_results(): + """ + 获取最近的执行结果列表。 + """ + url = f"{_get_base_url()}/execute/results" + res = _request(url, "GET") + return res.get("data", []) diff --git a/cmd/builtininstall/builtininstall.go b/cmd/builtininstall/builtininstall.go new file mode 100644 index 0000000..7bcefdf --- /dev/null +++ b/cmd/builtininstall/builtininstall.go @@ -0,0 +1,104 @@ +package builtininstall + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/utils" +) + +func printHelp() { + clibase.PrintSubCommandUsage("任务池内建依赖安装工具", "taskpool builtininstall", "", nil) +} + +// Run 执行内建包安装逻辑 +func Run(args []string) { + if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") { + printHelp() + return + } + + fs := flag.NewFlagSet("builtininstall", flag.ExitOnError) + fs.Usage = printHelp + + if err := fs.Parse(args); err != nil { + return + } + + fmt.Println(">> [Builtin] 开始为 mise 环境安装内建包...") + + // 1. 确定内建包路径 + // 优先使用 /www/builtin (Docker 环境),否则尝试相对于二进制文件的当前目录 + builtinPath := "/www/builtin" + if _, err := os.Stat(builtinPath); os.IsNotExist(err) { + // 回退到当前目录下的 builtin + pwd, _ := os.Getwd() + builtinPath = filepath.Join(pwd, "builtin") + } + + if _, err := os.Stat(builtinPath); os.IsNotExist(err) { + fmt.Printf(">> [Builtin] 错误: 找不到内建包目录: %s\n", builtinPath) + return + } + + // 2. 安装 Node.js 包 + installForLanguage("node", filepath.Join(builtinPath, "nodejs")) + + // 3. 安装 Python 包 + installForLanguage("python", filepath.Join(builtinPath, "python")) + + fmt.Println(">> [Builtin] 内建包安装流程完成") +} + +func installForLanguage(lang, pkgPath string) { + if _, err := os.Stat(pkgPath); os.IsNotExist(err) { + fmt.Printf(">> [Builtin] 警告: %s 的内建包目录不存在: %s\n", lang, pkgPath) + return + } + + versions, err := utils.ListMiseInstalledVersions(lang) + if err != nil { + fmt.Printf(">> [Builtin] 错误: 获取 %s 的 mise 版本列表失败: %v\n", lang, err) + return + } + + if len(versions) == 0 { + fmt.Printf(">> [Builtin] 未发现已安装的 %s 版本,跳过\n", lang) + return + } + + for _, v := range versions { + fmt.Printf(">> [Builtin] 正在为 %s@%s 安装内建包...\n", lang, v) + + var subCmdArgs []string + if lang == "node" { + // 使用 npm i -g 进行全局安装 + subCmdArgs = []string{"npm", "i", "-g", pkgPath} + } else { + // python 改为标准安装 (非 -e),避免 Docker 内软链接可能导致的路径丢失问题 + subCmdArgs = []string{"pip", "install", "--force-reinstall", pkgPath} + } + + // 构建参数列表: [mise, exec, lang@v, --, cmd...] + fullArgs := utils.BuildMiseCommandArgsSimple(subCmdArgs, lang, v) + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", append([]string{"/c"}, fullArgs...)...) + } else { + cmd = exec.Command(fullArgs[0], fullArgs[1:]...) + } + + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf(">> [Builtin] 错误: 为 %s@%s 安装失败: %v\n输出: %s\n", lang, v, err, string(out)) + } else { + fmt.Printf(">> [Builtin] 为 %s@%s 安装成功\n", lang, v) + } + } +} diff --git a/cmd/clibase/clibase.go b/cmd/clibase/clibase.go new file mode 100644 index 0000000..cec0261 --- /dev/null +++ b/cmd/clibase/clibase.go @@ -0,0 +1,89 @@ +package clibase + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/engigu/taskpool/internal/bootstrap" + "github.com/engigu/taskpool/internal/services" +) + +// InitContext 统一封装命令行所需的初始化上下文逻辑 +func InitContext(requireSettings bool) error { + bootstrap.InitBasicForCmd() + if requireSettings { + settingsService := services.NewSettingsService() + if err := settingsService.InitSettings(); err != nil { + return fmt.Errorf("初始化系统设置失败: %w", err) + } + } + return nil +} + +// PrintDBConfigHint 打印标准化的连接或检索失败时的排查指引 +func PrintDBConfigHint(commandExample string) { + fmt.Println(">> 提示: 程序当前可能连接到了默认的空 SQLite 数据库。") + fmt.Println(">> 若您的生产环境使用的是 MySQL 或指定路径配置,请在执行命令时携带配置文件路径环境变量,例如:") + fmt.Printf(">> BH_CONFIG_PATH=/app/data/config.ini taskpool %s\n", commandExample) +} + +// PrintSubCommandUsage 打印一致风格的子程序帮助信息 +func PrintSubCommandUsage(title, usageStr, exampleStr string, fs *flag.FlagSet) { + fmt.Fprintf(os.Stderr, "\n%s\n\n", title) + fmt.Fprintf(os.Stderr, "用法:\n") + fmt.Fprintf(os.Stderr, " %s\n\n", usageStr) + if fs != nil { + fmt.Fprintf(os.Stderr, "参数说明:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\n") + } + if exampleStr != "" { + fmt.Fprintf(os.Stderr, "示例:\n") + fmt.Fprintf(os.Stderr, "%s\n\n", exampleStr) + } +} + +// VisualFormat 根据字符的视觉显示列宽(中文字符/宽字符计为2列,ASCII计为1列), +// 将字符串进行精确等宽填充或安全截断追加 "..",确保混合字符输出下控制台表格严丝合缝强制对齐。 +func VisualFormat(s string, targetVisualWidth int) string { + w := 0 + var sb strings.Builder + runes := []rune(s) + + // 先计算总视觉宽度 + totalW := 0 + for _, r := range runes { + if r > 127 { + totalW += 2 + } else { + totalW += 1 + } + } + + if totalW <= targetVisualWidth { + return s + strings.Repeat(" ", targetVisualWidth-totalW) + } + + // 如果总宽度超出,进行精准截断并追加 ".." + maxContentW := targetVisualWidth - 2 + for _, r := range runes { + rw := 1 + if r > 127 { + rw = 2 + } + if w+rw > maxContentW { + break + } + sb.WriteRune(r) + w += rw + } + + res := sb.String() + ".." + // 补齐末尾可能相差的1个空格列宽 + if w+2 < targetVisualWidth { + res += strings.Repeat(" ", targetVisualWidth-(w+2)) + } + return res +} diff --git a/cmd/clibase/cliclient.go b/cmd/clibase/cliclient.go new file mode 100644 index 0000000..97d02a7 --- /dev/null +++ b/cmd/clibase/cliclient.go @@ -0,0 +1,40 @@ +package clibase + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/engigu/taskpool/internal/bootstrap" +) + +// CallInternalAPI 封装底层进程间 HTTP 通信,统一处理网络连接错误及业务级异常提取 +func CallInternalAPI(method, endpoint string, payload any) ([]byte, error) { + bodyBytes, statusCode, err := bootstrap.SendInternalRequest(method, endpoint, payload) + if err != nil { + return nil, fmt.Errorf("无法连接到主程序后台服务: %w", err) + } + + if statusCode != 200 { + return bodyBytes, fmt.Errorf("后台服务响应异常 (状态码: %d): %s", statusCode, strings.TrimSpace(string(bodyBytes))) + } + + // 尝试通用结构体嗅探,提取业务级逻辑拒绝原因 + var res struct { + Data struct { + Success *bool `json:"success"` + Error string `json:"error"` + } `json:"data"` + } + if err := json.Unmarshal(bodyBytes, &res); err == nil { + if res.Data.Success != nil && !*res.Data.Success { + errReason := res.Data.Error + if errReason == "" { + errReason = strings.TrimSpace(string(bodyBytes)) + } + return bodyBytes, fmt.Errorf("%s", errReason) + } + } + + return bodyBytes, nil +} diff --git a/cmd/clibase/clilog.go b/cmd/clibase/clilog.go new file mode 100644 index 0000000..1002ae0 --- /dev/null +++ b/cmd/clibase/clilog.go @@ -0,0 +1,71 @@ +package clibase + +import ( + "bytes" + "io" + "regexp" + "strings" +) + +// AnsiRegex 匹配终端 ANSI 控制序列的通用正则表达式 +var AnsiRegex = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]") + +// CleanWriter 过滤输出流中的终端回车符覆写及 ANSI 色彩代码 +type CleanWriter struct { + out io.Writer + buf []byte +} + +// NewCleanWriter 构造输出清洗器 +func NewCleanWriter(out io.Writer) *CleanWriter { + return &CleanWriter{out: out} +} + +func (c *CleanWriter) Write(p []byte) (n int, err error) { + c.buf = append(c.buf, p...) + + for { + idx := bytes.IndexAny(c.buf, "\r\n") + if idx == -1 { + break + } + + if c.buf[idx] == '\r' && idx == len(c.buf)-1 { + // 跨块截断的回车,等待下一块 + break + } + + char := c.buf[idx] + line := string(c.buf[:idx]) + c.buf = c.buf[idx+1:] + + if char == '\r' && len(c.buf) > 0 && c.buf[0] == '\n' { + c.buf = c.buf[1:] + char = '\n' + } + + s := AnsiRegex.ReplaceAllString(line, "") + + if char == '\r' { + continue // 忽略终端进度条的同行覆盖 + } + + if s != "" { + c.out.Write([]byte(s + "\n")) + } + } + return len(p), nil +} + +// Flush 输出末尾缓冲 +func (c *CleanWriter) Flush() { + if len(c.buf) > 0 { + s := string(c.buf) + s = strings.TrimSuffix(s, "\r") + s = AnsiRegex.ReplaceAllString(s, "") + if s != "" { + c.out.Write([]byte(s + "\n")) + } + c.buf = nil + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..e2fa4f8 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "github.com/engigu/taskpool/cmd/builtininstall" + "github.com/engigu/taskpool/cmd/depinstall" + "github.com/engigu/taskpool/cmd/reposync" + "github.com/engigu/taskpool/cmd/resetpwd" + "github.com/engigu/taskpool/cmd/restore" + "github.com/engigu/taskpool/cmd/task" + "github.com/engigu/taskpool/cmd/version" + "github.com/engigu/taskpool/cmd/webui" + // "github.com/engigu/taskpool/cmd/migrate" +) + +// CommandHandler 定义命令执行函数 +type CommandHandler func(args []string) + +// Handlers 维护了除了 server 之外的命令的执行入口 +var Handlers = map[string]CommandHandler{ + "reposync": reposync.Run, + "resetpwd": resetpwd.Run, + "restore": restore.Run, + "builtininstall": builtininstall.Run, + "task": task.Run, + "webui": webui.Run, + "version": version.Run, + "-v": version.Run, + "-V": version.Run, + "depinstall": depinstall.Run, + // "migrate": migrate.Run, +} diff --git a/cmd/depinstall/depinstall.go b/cmd/depinstall/depinstall.go new file mode 100644 index 0000000..c21b9cf --- /dev/null +++ b/cmd/depinstall/depinstall.go @@ -0,0 +1,184 @@ +package depinstall + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/deps" + "github.com/engigu/taskpool/internal/utils" +) + +// Run 依赖自动补全命令入口 +func Run(args []string) { + if len(args) == 0 { + fmt.Println("用法: taskpool depinstall ") + return + } + + logID := args[0] + fmt.Println(">> 提示: 依赖自动补全功能目前仅支持 Python 和 Node.js 环境,如有其他环境需求请及时反馈。") + + // 初始化基础环境和数据库连接 + if err := clibase.InitContext(true); err != nil { + fmt.Printf(">> 初始化环境失败: %v\n", err) + return + } + + var log models.TaskLog + if err := database.DB.Where("id = ?", logID).First(&log).Error; err != nil { + fmt.Printf(">> 未找到指定的任务日志 (ID: %s): %v\n", logID, err) + return + } + + var task models.Task + if err := database.DB.Where("id = ?", log.TaskID).First(&task).Error; err != nil { + fmt.Printf(">> 未找到对应的任务 (TaskID: %s): %v\n", log.TaskID, err) + return + } + + logOutput, err := utils.DecompressFromBase64(string(log.Output)) + if err != nil { + fmt.Printf(">> 解压日志失败: %v\n", err) + return + } + + // 找出任务配置的语言 + taskLangs := task.GetLanguages() + if len(taskLangs) == 0 { + fmt.Println(">> 提示: 当前任务未配置具体语言环境,请手动指定语言类型(例如 python3, node 等):") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + fmt.Println(">> 已取消补全。") + return + } + taskLangs = append(taskLangs, map[string]string{ + "name": input, + "version": "", + }) + } + + var allDetected []string + langToPkgMap := make(map[string][]string) + + for _, langMap := range taskLangs { + langName := langMap["name"] + if langName == "" { + continue + } + detected, found := deps.DetectMissingDependencies(langName, logOutput) + if found { + langToPkgMap[langName] = detected + allDetected = append(allDetected, detected...) + } + } + + reader := bufio.NewReader(os.Stdin) + + // 如果没有检测到任何缺失的包,允许用户手动输入 + if len(allDetected) == 0 { + fmt.Println(">> 分析完毕: 未从最近一次的任务运行日志中检测到缺失依赖模式。") + fmt.Println(">> 您可以手动输入想要安装的依赖包名称(多个包用空格分隔,若不安装请直接回车退出):") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + fmt.Println(">> 已退出依赖补全。") + return + } + // 默认分配到任务的第一个语言环境 + defaultLang := taskLangs[0]["name"] + langToPkgMap[defaultLang] = strings.Fields(input) + allDetected = append(allDetected, langToPkgMap[defaultLang]...) + } else { + fmt.Println(">> 分析结果: 从运行日志中检测到以下缺失依赖包:") + for langName, pkgs := range langToPkgMap { + fmt.Printf(" [%s]: %s\n", langName, strings.Join(pkgs, ", ")) + } + fmt.Println(">> 是否确认自动安装上述依赖包?(y/N):") + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(strings.ToLower(confirm)) + if confirm != "y" && confirm != "yes" { + fmt.Println(">> 用户已取消安装操作。") + return + } + } + + fmt.Println("==================================================================") + fmt.Println(">> 开始执行依赖安装,请稍候...") + fmt.Println("==================================================================") + + var failedPkgs []string + depService := services.NewDependencyService() + + for langName, pkgs := range langToPkgMap { + var langVersion string + for _, lm := range taskLangs { + if lm["name"] == langName { + langVersion = lm["version"] + break + } + } + + m := deps.GetManager(langName) + if m == nil { + fmt.Printf(">> 错误: 不支持的语言类型: %s\n", langName) + failedPkgs = append(failedPkgs, pkgs...) + continue + } + + for _, pkg := range pkgs { + dep := &models.Dependency{ + Name: pkg, + Language: langName, + LangVersion: langVersion, + } + + cmdStr, err := m.GetInstallCommand(dep) + if err != nil { + fmt.Printf(">> 无法生成 %s 包 [%s] 的安装命令: %v\n", langName, pkg, err) + failedPkgs = append(failedPkgs, pkg) + continue + } + + // 去除命令末尾的 success/failed echo 重定向,因为我们需要捕获退出状态并在控制台展示原始流程 + if idx := strings.Index(cmdStr, " && echo"); idx != -1 { + cmdStr = cmdStr[:idx] + } + + fmt.Printf(">> 正在安装 [%s] -> 执行指令: %s\n", pkg, cmdStr) + + execCmd := utils.NewShellCommandCmd(cmdStr) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + execCmd.Stdin = os.Stdin + + runErr := execCmd.Run() + if runErr != nil { + fmt.Printf(">> 【失败】依赖包 [%s] 安装出错。\n\n", pkg) + failedPkgs = append(failedPkgs, pkg) + } else { + fmt.Printf(">> 【成功】依赖包 [%s] 安装成功!\n\n", pkg) + // 成功后记录到依赖表 + _ = depService.Create(dep) + } + } + } + + fmt.Println("==================================================================") + if len(failedPkgs) > 0 { + fmt.Printf(">> 依赖补全已结束。其中以下依赖包安装失败,请用户自行判断/手动处理:\n") + for _, fp := range failedPkgs { + fmt.Printf(" - %s\n", fp) + } + } else { + fmt.Println(">> 恭喜!所有依赖包安装成功!") + } + fmt.Println("==================================================================") +} diff --git a/cmd/migrate/migrate.go b/cmd/migrate/migrate.go new file mode 100644 index 0000000..c3d54f6 --- /dev/null +++ b/cmd/migrate/migrate.go @@ -0,0 +1,29 @@ +package migrate + +import ( + "fmt" + "github.com/engigu/taskpool/internal/bootstrap" + "github.com/engigu/taskpool/internal/services" +) + +func Run(args []string) { + fmt.Println("Starting Migration V3...") + // 初始化基础环境(配置和数据库,但不运行常规 Migrate,因为我们想手动控) + // 不过 bootstrap.New() 会调用 Migrate(). + // 我们可以调用 InitBasic() + app := bootstrap.InitBasicForCmd() + if app == nil { + fmt.Println("Failed to initialize app") + return + } + + // 此时数据库已经连接,Migrate() 已经运行过了(因为 bootstrap.InitBasic 调用了 app.initDatabase) + // 由于我们在 Migrate() 中集成了 RunMigrationV3(),所以其实已经跑过了。 + // 如果用户想重复跑,或者单独跑: + err := services.RunMigrationV3() + if err != nil { + fmt.Printf("Migration failed: %v\n", err) + return + } + fmt.Println("Migration V3 completed successfully.") +} diff --git a/cmd/reposync/reposync.go b/cmd/reposync/reposync.go new file mode 100644 index 0000000..30ce244 --- /dev/null +++ b/cmd/reposync/reposync.go @@ -0,0 +1,775 @@ +package reposync + +import ( + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/services/repo" + "github.com/engigu/taskpool/internal/utils" +) + +type Config struct { + SourceType string + SourceURL string + TargetPath string + Branch string + Path string + SingleFile bool + Proxy string + ProxyURL string + AuthToken string + HttpProxy string + WhitelistPaths string // Comma or vertical line separated paths to preserve or filter (whitelist) + Blacklist string // Script filter blacklist keywords, vertical line separated + Dependence string // Script dependence file keywords, vertical line separated + Extensions string // Script file extensions, vertical line separated + TaskID string + RepoTaskID string + TaskLanguages string + TaskTimeout int + CommentToTask string + PreCommand string + PostCommand string + RepoName string +} + +func Run(args []string) { + fs := flag.NewFlagSet("reposync", flag.ExitOnError) + var cfg Config + fs.StringVar(&cfg.SourceType, "source-type", "git", "Source type: git or url") + fs.StringVar(&cfg.SourceURL, "source-url", "", "Source url") + fs.StringVar(&cfg.TargetPath, "target-path", "", "Target path") + fs.StringVar(&cfg.Branch, "branch", "", "Branch") + fs.StringVar(&cfg.Path, "path", "", "Path for sparse checkout") + fs.BoolVar(&cfg.SingleFile, "single-file", false, "Single file mode") + fs.StringVar(&cfg.Proxy, "proxy", "none", "Proxy type") + fs.StringVar(&cfg.ProxyURL, "proxy-url", "", "Custom proxy url") + fs.StringVar(&cfg.AuthToken, "auth-token", "", "Auth token") + fs.StringVar(&cfg.HttpProxy, "http-proxy", "", "Http proxy") + fs.StringVar(&cfg.WhitelistPaths, "whitelist-paths", "", "Separated paths to preserve or filter (whitelist)") + fs.StringVar(&cfg.Blacklist, "blacklist", "", "Script filter blacklist keywords (| separated)") + fs.StringVar(&cfg.Dependence, "dependence", "", "Script dependence keywords (| separated)") + fs.StringVar(&cfg.Extensions, "extensions", "", "Script extensions (| separated)") + fs.StringVar(&cfg.TaskID, "task-id", "", "Task ID for metadata") + fs.StringVar(&cfg.TaskLanguages, "task-langs", "", "Configured languages (JSON)") + fs.StringVar(&cfg.RepoTaskID, "repo-task-id", "", "Original Task ID") + fs.IntVar(&cfg.TaskTimeout, "task-timeout", 30, "Task timeout (minutes)") + fs.StringVar(&cfg.CommentToTask, "commenttotask", "false", "Compatible with QL format script comment parsing (true/false)") + fs.StringVar(&cfg.PreCommand, "pre-command", "", "Default pre-command for discovered tasks") + fs.StringVar(&cfg.PostCommand, "post-command", "", "Default post-command for discovered tasks") + fs.StringVar(&cfg.RepoName, "repo-name", "", "Custom repository directory name") + + printHelp := func() { + fmt.Fprintf(os.Stderr, "\n任务池仓库同步工具 (Reposync)\n\n") + fmt.Fprintf(os.Stderr, "用法:\n") + fmt.Fprintf(os.Stderr, " taskpool reposync [参数]\n\n") + fmt.Fprintf(os.Stderr, "参数详情:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\n示例:\n") + fmt.Fprintf(os.Stderr, " taskpool reposync --source-url https://github.com/xxx/repo.git --target-path $SCRIPTS_DIR$/repo1\n\n") + } + + if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") { + printHelp() + return + } + + fs.Usage = printHelp + + if err := fs.Parse(args); err != nil { + return + } + + if cfg.SourceURL == "" { + fmt.Fprintf(os.Stderr, "错误: 必须提供 --source-url 参数\n") + fs.Usage() + return + } + + // 处理 $SCRIPTS_DIR$ 代号替换 + if strings.Contains(cfg.TargetPath, constant.ScriptsDirPlaceholder) { + scriptsDir := os.Getenv("BH_SCRIPTS_DIR") + if scriptsDir != "" { + cfg.TargetPath = filepath.Clean(strings.ReplaceAll(cfg.TargetPath, constant.ScriptsDirPlaceholder, scriptsDir)) + } + } + + fmt.Println("========================================") + fmt.Println(" 仓库同步任务开始 ") + fmt.Println("========================================") + fmt.Printf("[1/3] 解析同步参数: %s\n", strings.Join(args, " ")) + + if cfg.SourceType == "git" { + fmt.Printf("[2/3] 正在通过 Git 同步内容...\n") + syncGit(cfg) + } else { + fmt.Printf("[2/3] 正在通过 URL 下载内容...\n") + syncURL(cfg) + } + + // 执行前置指令 + if cfg.PreCommand != "" { + fmt.Printf("[准备] 执行同步前指令: %s\n", cfg.PreCommand) + // 计算当前仓库真实的物理路径 + repoDir := getActualRepoDir(cfg) + fmt.Printf("[准备] 工作目录: %s\n", repoDir) + fmt.Printf("[准备] 注入环境变量: CURR_REPO_DIR=%s\n", repoDir) + + shell, shellArgs := utils.GetShellCommand(cfg.PreCommand) + envs := append(os.Environ(), "CURR_REPO_DIR="+repoDir) + runCmd(append([]string{shell}, shellArgs...), repoDir, envs) + } + + // 执行脚本过滤(仅限 git 模式,url 加载通常为单文件,暂不处理过滤) + if cfg.SourceType == "git" { + fmt.Printf("[3/3] 正在执行脚本过滤与文件清理...\n") + filterFiles(cfg) + + if cfg.TaskID != "" { + upsertedIDs, deletedIDs := repo.ParseRepoScriptsAndAddCron(cfg.TaskID, os.Stdout, cfg.CommentToTask == "true") + if len(upsertedIDs) > 0 || len(deletedIDs) > 0 { + notifyMainServerToSyncRepoTasks(cfg.TaskID, upsertedIDs, deletedIDs) + } + } + } + + // 执行后置指令 + if cfg.PostCommand != "" { + fmt.Printf("[收尾] 执行同步后指令: %s\n", cfg.PostCommand) + + // 计算当前仓库真实的物理路径 + repoDir := getActualRepoDir(cfg) + fmt.Printf("[收尾] 工作目录: %s\n", repoDir) + fmt.Printf("[收尾] 注入环境变量: CURR_REPO_DIR=%s\n", repoDir) + + shell, shellArgs := utils.GetShellCommand(cfg.PostCommand) + envs := append(os.Environ(), "CURR_REPO_DIR="+repoDir) + runCmd(append([]string{shell}, shellArgs...), repoDir, envs) + } + + fmt.Println("\n========================================") + fmt.Println(" 仓库同步任务完成 ") + fmt.Println("========================================") +} + +func getActualRepoDir(cfg Config) string { + if cfg.SourceType == "git" { + repoName := cfg.RepoName + if repoName == "" { + repoName = utils.GetRepoIdentifier(cfg.SourceURL, cfg.Branch) + } + if repoName == "." { + return cfg.TargetPath + } + return filepath.Join(cfg.TargetPath, repoName) + } + return cfg.TargetPath +} + +func notifyMainServerToSyncRepoTasks(repoID string, upsertedIDs []string, deletedIDs []string) { + _, err := clibase.CallInternalAPI("POST", "/internal/tasks/sync-repo-status", map[string]interface{}{ + "repo_id": repoID, + "upserted_ids": upsertedIDs, + "deleted_ids": deletedIDs, + }) + if err != nil { + fmt.Printf(">> [通知] 调度器同步失败: %v\n", err) + return + } + fmt.Println(">> [通知] 已成功将变动任务增量同步至主程序调度器") +} + +func syncGit(cfg Config) { + env := os.Environ() + + if isRawFileURL(cfg.SourceURL) { + fmt.Println("检测到 raw 文件 URL,自动切换到 URL 下载模式") + syncURL(cfg) + return + } + + if cfg.HttpProxy != "" { + env = append(env, "http_proxy="+cfg.HttpProxy, "https_proxy="+cfg.HttpProxy) + } + + repoURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL) + if cfg.AuthToken != "" && strings.HasPrefix(repoURL, "https://") { + repoURL = strings.Replace(repoURL, "https://", "https://"+cfg.AuthToken+"@", 1) + } + + dest := cfg.TargetPath + + if cfg.Path != "" && cfg.SingleFile { + syncGitFile(cfg, repoURL, env) + return + } + + gitDir := filepath.Join(dest, ".git") + if isDir(dest) && !pathExists(gitDir) { + repoName := cfg.RepoName + if repoName == "" { + repoName = utils.GetRepoIdentifier(cfg.SourceURL, cfg.Branch) + } + if repoName != "." { + dest = filepath.Join(dest, repoName) + fmt.Printf("目标路径自动追加仓库名: %s\n", dest) + gitDir = filepath.Join(dest, ".git") + } else { + fmt.Printf("目标路径使用当前目录 (不追加仓库名): %s\n", dest) + } + } + + restore := preserve(dest, cfg.WhitelistPaths) + defer restore() + + if pathExists(gitDir) { + fmt.Println("检测到已存在仓库,正在更新...") + runCmd([]string{"git", "fetch", "--all"}, dest, env) + + targetBranch := cfg.Branch + if targetBranch != "" { + // 如果切换了分支,或者当前分支偏离,强制切换并对齐远程 + runCmd([]string{"git", "checkout", "-B", targetBranch, "origin/" + targetBranch}, dest, env) + } else { + targetBranch = getCurrentBranch(dest, env) + } + + if targetBranch != "" { + fmt.Printf("执行强制同步 (reset --hard origin/%s)\n", targetBranch) + runCmd([]string{"git", "reset", "--hard", "origin/" + targetBranch}, dest, env) + } else { + runCmd([]string{"git", "pull", "--rebase"}, dest, env) + } + } else { + fmt.Println("执行 git clone") + parentDir := filepath.Dir(dest) + if parentDir != "" { + os.MkdirAll(parentDir, 0755) + } + + if pathExists(dest) && !isDirEmpty(dest) { + // If we still have files after preservation, warn but maybe continue if it's just leftovers that git can handle? + // Actually git clone requires an empty dir. + fmt.Printf("警告: 目标目录 '%s' 不为空,尝试清理非保护文件...\n", dest) + // Optional: delete everything else? User might not want that. + // For now, keep the error but it's less likely to occur if preservation moved things out. + fmt.Println("提示: 请清空目标目录或指定一个新目录") + os.Exit(1) + } + // If dest exists but is empty now, git clone might still complain if the directory itself exists? + // No, git clone works if dir is empty. + + cloneCmd := []string{"git", "clone", "--depth", "1"} + if cfg.Branch != "" { + cloneCmd = append(cloneCmd, "-b", cfg.Branch) + } + + if cfg.Path != "" { + cloneCmd = append(cloneCmd, "--filter=blob:none", "--no-checkout", repoURL, dest) + runCmd(cloneCmd, "", env) + runCmd([]string{"git", "sparse-checkout", "init", "--cone"}, dest, env) + runCmd([]string{"git", "sparse-checkout", "set", cfg.Path}, dest, env) + runCmd([]string{"git", "checkout"}, dest, env) + } else { + cloneCmd = append(cloneCmd, repoURL, dest) + runCmd(cloneCmd, "", env) + } + } + fmt.Println("同步完成") +} + +func syncURL(cfg Config) { + downloadURL := buildProxyURL(cfg.SourceURL, cfg.Proxy, cfg.ProxyURL) + fmt.Printf("下载地址: %s\n", downloadURL) + dest := cfg.TargetPath + + if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") { + urlPath := strings.Split(cfg.SourceURL, "?")[0] + filename := filepath.Base(urlPath) + if filename == "" { + filename = "downloaded_file" + } + dest = filepath.Join(dest, filename) + fmt.Printf("目标文件: %s\n", dest) + } + + restore := preserve(cfg.TargetPath, cfg.WhitelistPaths) + defer restore() + + downloadFile(downloadURL, dest, cfg.AuthToken) +} + +func syncGitFile(cfg Config, repoURL string, env []string) { + sourceURL := cfg.SourceURL + filePath := cfg.Path + dest := cfg.TargetPath + + if isDir(dest) || strings.HasSuffix(dest, string(os.PathSeparator)) || strings.HasSuffix(dest, "/") { + filename := filepath.Base(filePath) + dest = filepath.Join(dest, filename) + fmt.Printf("检测到目标路径为目录 '%s',自动修正为: '%s'\n", cfg.TargetPath, dest) + } + + branch := cfg.Branch + if branch == "" { + branch = getRemoteDefaultBranch(repoURL, env) + } + + cleanURL := strings.TrimSuffix(cfg.SourceURL, ".git") + rawURL := "" + + if strings.Contains(sourceURL, "github.com") { + base := strings.Replace(strings.TrimSuffix(cfg.SourceURL, ".git"), "github.com", "raw.githubusercontent.com", 1) + rawURL = fmt.Sprintf("%s/%s/%s", base, branch, filePath) + } else if strings.Contains(sourceURL, "gitlab.com") { + rawURL = fmt.Sprintf("%s/-/raw/%s/%s", cleanURL, branch, filePath) + } else if strings.Contains(sourceURL, "gitee.com") { + rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath) + } else { + rawURL = fmt.Sprintf("%s/raw/%s/%s", cleanURL, branch, filePath) + } + + rawURL = buildProxyURL(rawURL, cfg.Proxy, cfg.ProxyURL) + downloadFile(rawURL, dest, cfg.AuthToken) +} + +func getRemoteDefaultBranch(repoURL string, env []string) string { + fmt.Printf("正在检测远程仓库默认分支: %s\n", repoURL) + cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD") + cmd.Env = env + out, err := cmd.Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + parts := strings.Fields(line) + if len(parts) >= 2 && parts[0] == "ref:" && strings.Contains(parts[1], "refs/heads/") { + branch := strings.TrimPrefix(parts[1], "refs/heads/") + fmt.Printf("检测到默认分支: %s\n", branch) + return branch + } + } + } + fmt.Println("无法检测到默认分支,回退使用 'main'") + return "main" +} + +func buildProxyURL(url string, proxyType string, proxyURL string) string { + if proxyType == "" || proxyType == "none" { + return url + } + + // 如果 URL 已经包含明显的代理前缀 (如用户手动填写的 http://ghproxy.com/...) + // 则跳过内置代理逻辑 + if strings.Contains(url, "googo.win") || (proxyType == "custom" && strings.HasPrefix(url, proxyURL)) { + return url + } + + base := "" + if proxyType == "ghproxy" { + base = "https://gh-proxy.com/" + } else if proxyType == "mirror" { + base = "https://mirror.ghproxy.com/" + } else if proxyType == "custom" && proxyURL != "" { + base = strings.TrimSuffix(proxyURL, "/") + "/" + } + + if base != "" && strings.HasPrefix(url, "http") && !strings.HasPrefix(url, base) { + return base + url + } + return url +} + +func downloadFile(url, dest, authToken string) { + fmt.Printf("下载地址: %s\n", url) + fmt.Printf("目标路径: %s\n", dest) + + parentDir := filepath.Dir(dest) + if parentDir != "" { + os.MkdirAll(parentDir, 0755) + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + fmt.Printf("下载准备失败: %v\n", err) + os.Exit(1) + } + if authToken != "" { + req.Header.Set("Authorization", "token "+authToken) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; reposync)") + + client := &http.Client{Timeout: 300 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("下载请求失败: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + fmt.Printf("下载失败, HTTP 状态码: %d\n", resp.StatusCode) + os.Exit(1) + } + + out, err := os.Create(dest) + if err != nil { + fmt.Printf("创建文件失败: %v\n", err) + os.Exit(1) + } + defer out.Close() + + n, err := io.Copy(out, resp.Body) + if err != nil { + fmt.Printf("写入数据失败: %v\n", err) + os.Exit(1) + } + + fmt.Printf("文件大小: %d 字节\n", n) + fmt.Println("下载完成") +} + +func isRawFileURL(url string) bool { + rawPatterns := []string{ + "raw.githubusercontent.com", + "/raw/", + "/-/raw/", + "/blob/", + } + for _, p := range rawPatterns { + if strings.Contains(url, p) { + return true + } + } + return false +} + +func runCmd(args []string, dir string, env []string) { + fmt.Printf(">> %s\n", strings.Join(args, " ")) + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + cmd.Env = env + + cw := clibase.NewCleanWriter(os.Stdout) + cmd.Stdout = cw + cmd.Stderr = cw + + if err := cmd.Run(); err != nil { + cw.Flush() + fmt.Printf("命令执行失败: %v\n", err) + os.Exit(1) + } + cw.Flush() +} + +func isDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil || !os.IsNotExist(err) +} + +func isDirEmpty(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + _, err = f.Readdirnames(1) + return err == io.EOF +} + +func getCurrentBranch(dir string, env []string) string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = dir + cmd.Env = env + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// preserve moves specified paths to a temporary location and returns a function to restore them +func preserve(baseDir string, paths string) func() { + if paths == "" || !pathExists(baseDir) { + return func() {} + } + + preservedList := strings.Split(paths, ",") + // 优化:将临时目录创建在 baseDir 同一级或内部,确保在同一个文件系统,使得 Rename 是 O(1) 瞬时完成的 + tmpParent, err := os.MkdirTemp(baseDir, ".taskpool_sync_preserve_*") + if err != nil { + fmt.Printf("警告: 无法在目标目录创建临时目录用于保留文件: %v\n", err) + return func() {} + } + + type preservedItem struct { + relPath string + tmpPath string + } + var items []preservedItem + processed := make(map[string]bool) + + for _, p := range preservedList { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // Support glob matching + pattern := filepath.Join(baseDir, p) + matches, err := filepath.Glob(pattern) + if err != nil { + fmt.Printf("警告: 路径模式无效 %s: %v\n", p, err) + continue + } + + // If literal path exists but Glob didn't find it (common for direct dir reference), add it manually + if len(matches) == 0 && pathExists(pattern) { + matches = []string{pattern} + } + + for _, fullPath := range matches { + relPath, err := filepath.Rel(baseDir, fullPath) + // 同时要排除掉临时目录本身以及上级路径 + if err != nil || strings.HasPrefix(relPath, "..") || relPath == "." || strings.HasPrefix(relPath, ".taskpool_sync_preserve") { + continue + } + + if processed[relPath] { + continue + } + processed[relPath] = true + + tmpPath := filepath.Join(tmpParent, relPath) + os.MkdirAll(filepath.Dir(tmpPath), 0755) + + fmt.Printf("正在保护路径: %s\n", relPath) + if err := os.Rename(fullPath, tmpPath); err == nil { + items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath}) + } else { + // Rename might fail across filesystems, try copy + if err := utils.CopyPath(fullPath, tmpPath); err == nil { + os.RemoveAll(fullPath) + items = append(items, preservedItem{relPath: relPath, tmpPath: tmpPath}) + } else { + fmt.Printf("警告: 无法保护路径 %s: %v\n", relPath, err) + } + } + } + } + + return func() { + // Restore in reverse order to handle nested structures correctly if they were picked up separately + for i := len(items) - 1; i >= 0; i-- { + item := items[i] + destPath := filepath.Join(baseDir, item.relPath) + os.MkdirAll(filepath.Dir(destPath), 0755) + + if pathExists(destPath) { + fmt.Printf("目标已存在,覆盖恢复保护路径: %s\n", item.relPath) + os.RemoveAll(destPath) + } else { + fmt.Printf("正在恢复保护路径: %s\n", item.relPath) + } + + if err := os.Rename(item.tmpPath, destPath); err != nil { + // Fallback to copy + utils.CopyPath(item.tmpPath, destPath) + } + } + os.RemoveAll(tmpParent) + } +} + +// filterFiles performs script filtering based on whitelist, blacklist, dependence and extensions. +func filterFiles(cfg Config) { + // If no filtering is specified, do nothing. + if cfg.WhitelistPaths == "" && cfg.Blacklist == "" && cfg.Dependence == "" && cfg.Extensions == "" { + return + } + + dest := getActualRepoDir(cfg) + + fmt.Printf("开始执行脚本过滤: %s\n", dest) + + whitelist := splitKeywords(cfg.WhitelistPaths) + blacklist := splitKeywords(cfg.Blacklist) + dependence := splitKeywords(cfg.Dependence) + extensions := splitKeywords(cfg.Extensions) + + // We'll collect files to delete to avoid modifying while walking if possible. + // But os.RemoveAll is fine. + + count := 0 + filepath.Walk(dest, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + + rel, _ := filepath.Rel(dest, path) + rel = filepath.ToSlash(rel) + filename := info.Name() + + // 1. Check dependence: always keep + if matchesAny(rel, filename, dependence) { + return nil + } + + // 2. Check extensions: delete if not matched and extensions is specified + if len(extensions) > 0 { + ext := strings.TrimPrefix(filepath.Ext(filename), ".") + matchedExt := false + for _, e := range extensions { + if strings.EqualFold(ext, strings.TrimPrefix(e, ".")) { + matchedExt = true + break + } + } + if !matchedExt { + fmt.Printf("过滤文件 (后缀不符): %s\n", rel) + os.Remove(path) + count++ + return nil + } + } + + // 3. Check blacklist: delete if matched + if matchesAny(rel, filename, blacklist) { + fmt.Printf("过滤文件 (黑名单): %s\n", rel) + os.Remove(path) + count++ + return nil + } + + // 4. Check whitelist: delete if NOT matched and whitelist is specified + if len(whitelist) > 0 { + if !matchesAny(rel, filename, whitelist) { + fmt.Printf("过滤文件 (不在白名单): %s\n", rel) + os.Remove(path) + count++ + return nil + } + } + + return nil + }) + + if count > 0 { + fmt.Printf("过滤完成,共删除 %d 个不符合要求的文件\n", count) + // Try to clean up empty directories + cleanEmptyDirs(dest) + } +} + +func splitKeywords(s string) []string { + if s == "" { + return nil + } + // Try to split by common separators for compatibility + var parts []string + if strings.Contains(s, "|") { + parts = strings.Split(s, "|") + } else if strings.Contains(s, ",") { + parts = strings.Split(s, ",") + } else { + parts = []string{s} + } + + var res []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + res = append(res, p) + } + } + return res +} + +func matchesAny(rel, filename string, keywords []string) bool { + if len(keywords) == 0 { + return false + } + for _, k := range keywords { + // 1. 尝试作为正则整体进行匹配,默认不区分大小写 (?i) + // 如果关键字不包含正则元字符,则补齐 (?i) 开启忽略大小写 + pattern := k + if !strings.HasPrefix(pattern, "(?i)") { + pattern = "(?i)" + pattern + } + + reg, err := regexp.Compile(pattern) + if err == nil { + // 优先匹配文件名(解决 ^jd[^_] 这种锚点在相对路径下失效的问题) + if reg.MatchString(filename) || reg.MatchString(rel) { + return true + } + } else { + // 回退逻辑:全小写包含判断 + kLower := strings.ToLower(k) + if strings.Contains(strings.ToLower(rel), kLower) || strings.Contains(strings.ToLower(filename), kLower) { + return true + } + } + } + return false +} + +func cleanEmptyDirs(root string) { + // Post-order traversal to clean up empty dirs + filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + return nil + } + if path == root { + return nil + } + if info.Name() == ".git" { + return filepath.SkipDir + } + return nil + }) + + // Actually we need to do this recursively or multiple times. + // A simpler way: + entries, _ := os.ReadDir(root) + for _, entry := range entries { + if entry.IsDir() { + if entry.Name() == ".git" { + continue + } + dirPath := filepath.Join(root, entry.Name()) + cleanEmptyDirs(dirPath) + // Check if now empty + if isDirEmpty(dirPath) { + os.Remove(dirPath) + } + } + } + +} diff --git a/cmd/resetpwd/resetpwd.go b/cmd/resetpwd/resetpwd.go new file mode 100644 index 0000000..2549526 --- /dev/null +++ b/cmd/resetpwd/resetpwd.go @@ -0,0 +1,82 @@ +package resetpwd + +import ( + "bufio" + "flag" + "fmt" + "os" + "strings" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" +) + +func printHelp() { + clibase.PrintSubCommandUsage("任务池用户密码重置工具", "taskpool resetpwd [用户名]", " taskpool resetpwd admin", nil) +} + +func Run(args []string) { + if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") { + printHelp() + return + } + + fs := flag.NewFlagSet("resetpwd", flag.ExitOnError) + fs.Usage = printHelp + + if err := fs.Parse(args); err != nil { + return + } + + if err := clibase.InitContext(true); err != nil { + fmt.Println(err) + return + } + userService := services.NewUserService() + + var username string + parsedArgs := fs.Args() + if len(parsedArgs) >= 1 { + username = parsedArgs[0] + } else { + username = "admin" + } + + fmt.Printf("此操作将重置用户 [%s] 的密码,是否继续? (y/N): ", username) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "y" && answer != "yes" { + fmt.Println("操作已取消。") + return + } + + fmt.Printf("请输入用户 [%s] 的新密码 (留空则自动随机生成): ", username) + inputPwd, _ := reader.ReadString('\n') + newPassword := strings.TrimSpace(inputPwd) + if newPassword == "" { + newPassword = utils.RandomString(12) + fmt.Println("未输入密码,系统已自动生成。") + } + + user := userService.GetUserByUsername(username) + if user == nil { + fmt.Printf("找不到用户 [%s]\n", username) + clibase.PrintDBConfigHint("resetpwd " + username) + return + } + + err := userService.UpdatePassword(user.ID, newPassword) + if err != nil { + fmt.Printf("重置密码失败: %v\n", err) + return + } + + fmt.Println("--------------------------------------------------") + fmt.Printf("用户 [%s] 密码已重置成功:\n", username) + fmt.Printf("新密码: %s\n", newPassword) + fmt.Println("请妥善保管您的新密码,并登录后及时修改。") + fmt.Println("--------------------------------------------------") +} diff --git a/cmd/restore/restore.go b/cmd/restore/restore.go new file mode 100644 index 0000000..851a7bc --- /dev/null +++ b/cmd/restore/restore.go @@ -0,0 +1,64 @@ +package restore + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/services" +) + +func printHelp() { + clibase.PrintSubCommandUsage("任务池系统数据恢复工具", "taskpool restore <备份文件.zip>", " taskpool restore backup_20231027.zip", nil) +} + +func Run(args []string) { + if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") { + printHelp() + return + } + + fs := flag.NewFlagSet("restore", flag.ExitOnError) + fs.Usage = printHelp + + if err := fs.Parse(args); err != nil { + return + } + + parsedArgs := fs.Args() + if len(parsedArgs) < 1 { + fmt.Fprintf(os.Stderr, "错误: 必须提供备份文件路径\n") + fs.Usage() + return + } + + backupFile := parsedArgs[0] + absPath, err := filepath.Abs(backupFile) + if err != nil { + fmt.Printf("文件路径解析失败: %v\n", err) + os.Exit(1) + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + fmt.Printf("错误: 备份文件 '%s' 不存在\n", absPath) + os.Exit(1) + } + + // 必须初始化环境与数据库才能恢复数据 + clibase.InitContext(false) + + backupService := services.NewBackupService() + fmt.Printf("正在从 '%s' 恢复系统数据,请勿强制中断...\n", absPath) + err = backupService.Restore(absPath) + if err != nil { + fmt.Printf("恢复备份失败: %v\n", err) + os.Exit(1) + } + + fmt.Println("--------------------------------------------------") + fmt.Println("系统备份恢复成功!") + fmt.Println("注意:部分设定可能需要重启后台服务后才能完全生效。") + fmt.Println("--------------------------------------------------") +} diff --git a/cmd/task/task.go b/cmd/task/task.go new file mode 100644 index 0000000..b86eb44 --- /dev/null +++ b/cmd/task/task.go @@ -0,0 +1,398 @@ +package task + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +// 打印主帮助 +func printMainHelp() { + fmt.Fprintf(os.Stderr, "\n任务池任务命令行管理工具 (Task CLI)\n\n") + fmt.Fprintf(os.Stderr, "说明:\n") + fmt.Fprintf(os.Stderr, " 本工具原生兼容管理普通任务 (task) 与仓库同步任务 (repo)。\n") + fmt.Fprintf(os.Stderr, " 操作目标支持传入精确任务ID、任务名称模糊/精准查找,或使用快捷字面量 'repo' 一键操作主力仓库。\n\n") + fmt.Fprintf(os.Stderr, "用法:\n") + fmt.Fprintf(os.Stderr, " taskpool task <子命令> [参数]\n\n") + fmt.Fprintf(os.Stderr, "可用子命令:\n") + fmt.Fprintf(os.Stderr, " list 查询并输出任务列表\n") + fmt.Fprintf(os.Stderr, " run 手动立即触发执行指定的任务或仓库\n") + fmt.Fprintf(os.Stderr, " enable 启用指定的任务或仓库(同步加入后台调度队列)\n") + fmt.Fprintf(os.Stderr, " disable 禁用指定的任务或仓库(同步从后台调度队列摘除)\n") + fmt.Fprintf(os.Stderr, " status 查看指定任务或仓库最近一次执行的完整输出与状态\n") + fmt.Fprintf(os.Stderr, " history 查看指定任务或仓库近期的多次执行流水记录\n\n") + fmt.Fprintf(os.Stderr, "使用 'taskpool task <子命令> --help' 查看具体子命令的参数说明和示例。\n\n") +} + +// resolveTaskID 智能解析目标任务ID:支持直接传入真实ID、任务名称,或传入 "repo" 快捷操作系统中唯一的仓库同步任务 +func resolveTaskID(input string) string { + var t models.Task + // 1. 尝试按精确 ID 匹配 + if res := database.DB.Where("id = ?", input).Limit(1).Find(&t); res.Error == nil && res.RowsAffected > 0 { + return t.ID + } + + // 2. 如果输入字面量为 "repo",尝试匹配 type = 'repo' 的记录 + if strings.ToLower(input) == "repo" { + var repos []models.Task + if res := database.DB.Where("type = ?", constant.TaskTypeRepo).Find(&repos); res.Error == nil { + if len(repos) == 1 { + fmt.Printf(">> 智能匹配到唯一的仓库任务: [%s] (ID: %s)\n", repos[0].Name, repos[0].ID) + return repos[0].ID + } else if len(repos) > 1 { + fmt.Fprintf(os.Stderr, ">> 提示: 系统中存在多个 repo 类型的仓库任务,请指定具体的仓库名称或ID进行精确操作。\n") + return input + } + } + } + + // 3. 尝试按名称精准或模糊匹配 + var namedTasks []models.Task + if res := database.DB.Where("name = ?", input).Find(&namedTasks); res.Error == nil && len(namedTasks) > 0 { + if len(namedTasks) == 1 { + fmt.Printf(">> 智能匹配到目标任务: [%s] (ID: %s)\n", namedTasks[0].Name, namedTasks[0].ID) + return namedTasks[0].ID + } + fmt.Fprintf(os.Stderr, ">> 提示: 存在多个同名任务 [%s],请使用精确的任务ID进行操作。\n", input) + return input + } + + // 尝试名称模糊匹配 (LIKE) + if res := database.DB.Where("name LIKE ?", "%"+input+"%").Find(&namedTasks); res.Error == nil && len(namedTasks) == 1 { + fmt.Printf(">> 模糊匹配到唯一的任务: [%s] (ID: %s)\n", namedTasks[0].Name, namedTasks[0].ID) + return namedTasks[0].ID + } + + // 默认原样返回 + return input +} + +// Run 任务命令行入口 +func Run(args []string) { + if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { + printMainHelp() + return + } + + subCommand := args[0] + subArgs := args[1:] + + switch subCommand { + case "list": + runList(subArgs) + case "run": + runExecute(subArgs) + case "enable", "disable": + runToggle(subCommand, subArgs) + case "status": + runStatus(subArgs) + case "history": + runHistory(subArgs) + default: + fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand) + printMainHelp() + } +} + +func runList(args []string) { + fs := flag.NewFlagSet("list", flag.ExitOnError) + namePtr := fs.String("name", "", "按任务名称或备注进行模糊筛选") + typePtr := fs.String("type", "", "按任务类型筛选 (例如: task, repo)") + pagePtr := fs.Int("page", 1, "查询页码") + sizePtr := fs.Int("size", 20, "每页展示条数") + + fs.Usage = func() { + clibase.PrintSubCommandUsage("任务池任务列表查询工具", "taskpool task list [参数]", " taskpool task list\n taskpool task list -page 2 -size 10\n taskpool task list -name \"签到\"", fs) + } + + if err := fs.Parse(args); err != nil { + return + } + + clibase.InitContext(false) + + var total int64 + query := database.DB.Model(&models.Task{}) + if *namePtr != "" { + query = query.Where("name LIKE ? OR remark LIKE ?", "%"+*namePtr+"%", "%"+*namePtr+"%") + } + if *typePtr != "" { + query = query.Where("type = ?", *typePtr) + } + query.Count(&total) + + offset := (*pagePtr - 1) * *sizePtr + if offset < 0 { + offset = 0 + } + + var tasks []models.Task + query.Order("created_at DESC").Limit(*sizePtr).Offset(offset).Find(&tasks) + + fmt.Println(strings.Repeat("=", 90)) + fmt.Printf("%s | %s | %s | %s | %s\n", + clibase.VisualFormat("任务ID", 20), + clibase.VisualFormat("任务名称", 28), + clibase.VisualFormat("Cron规则", 18), + clibase.VisualFormat("类型", 6), + clibase.VisualFormat("状态", 6), + ) + fmt.Println(strings.Repeat("-", 90)) + for _, t := range tasks { + cron := t.Schedule + if cron == "" { + cron = "-" + } + status := "启用" + if !utils.DerefBool(t.Enabled, true) { + status = "禁用" + } + fmt.Printf("%s | %s | %s | %s | %s\n", + clibase.VisualFormat(t.ID, 20), + clibase.VisualFormat(t.Name, 28), + clibase.VisualFormat(cron, 18), + clibase.VisualFormat(t.Type, 6), + clibase.VisualFormat(status, 6), + ) + } + fmt.Println(strings.Repeat("=", 90)) + totalPages := (total + int64(*sizePtr) - 1) / int64(*sizePtr) + if totalPages == 0 { + totalPages = 1 + } + fmt.Printf("共查询到 %d 个任务记录,当前展示第 %d/%d 页 (每页 %d 条)。\n", total, *pagePtr, totalPages, *sizePtr) + fmt.Printf("提示: 追加参数 (例如 '-page 2 -size 50') 即可灵活查看指定页码或调整展示数量。\n") +} + +func runExecute(args []string) { + fs := flag.NewFlagSet("run", flag.ExitOnError) + fs.Usage = func() { + clibase.PrintSubCommandUsage("任务池手动任务触发工具", "taskpool task run <任务ID/名称/repo>", " taskpool task run a1b2c3d4\n taskpool task run \"自动签到\"\n taskpool task run repo", nil) + } + + if err := fs.Parse(args); err != nil { + return + } + + parsedArgs := fs.Args() + if len(parsedArgs) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n") + fs.Usage() + return + } + taskID := parsedArgs[0] + + clibase.InitContext(false) + taskID = resolveTaskID(taskID) + + _, err := clibase.CallInternalAPI("POST", "/internal/tasks/execute/"+taskID, map[string]interface{}{}) + if err != nil { + fmt.Printf(">> 任务触发失败: %v\n", err) + return + } + + fmt.Printf(">> 任务 [%s] 触发指令下发成功!已进入后台调度队列排队或执行。\n", taskID) + fmt.Printf(">> 提示: 可以使用 'taskpool task status %s' 查看近期执行输出。\n", taskID) +} + +func runToggle(action string, args []string) { + fs := flag.NewFlagSet(action, flag.ExitOnError) + actionName := "启用" + targetEnabled := true + if action == "disable" { + actionName = "禁用" + targetEnabled = false + } + + fs.Usage = func() { + clibase.PrintSubCommandUsage(fmt.Sprintf("任务池任务%s工具", actionName), fmt.Sprintf("taskpool task %s <任务ID/名称/repo>", action), fmt.Sprintf(" taskpool task %s a1b2c3d4\n taskpool task %s repo", action, action), nil) + } + + if err := fs.Parse(args); err != nil { + return + } + + parsedArgs := fs.Args() + if len(parsedArgs) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n") + fs.Usage() + return + } + taskID := parsedArgs[0] + + clibase.InitContext(false) + taskID = resolveTaskID(taskID) + + _, err := clibase.CallInternalAPI("POST", "/internal/tasks/toggle/"+taskID, map[string]interface{}{ + "enabled": targetEnabled, + }) + if err != nil { + fmt.Printf(">> 切换状态操作失败: %v\n", err) + return + } + + fmt.Printf(">> 任务 [%s] 已成功%s!\n", taskID, actionName) +} + +func runStatus(args []string) { + fs := flag.NewFlagSet("status", flag.ExitOnError) + fs.Usage = func() { + clibase.PrintSubCommandUsage("任务池任务执行状态与日志查看工具", "taskpool task status <任务ID/名称/repo> [日志ID]", " taskpool task status a1b2c3d4\n taskpool task status repo\n taskpool task status \"自动签到\"", nil) + } + + if err := fs.Parse(args); err != nil { + return + } + + parsedArgs := fs.Args() + if len(parsedArgs) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n") + fs.Usage() + return + } + taskID := parsedArgs[0] + var specificLogID string + if len(parsedArgs) > 1 { + specificLogID = parsedArgs[1] + } + + clibase.InitContext(false) + taskID = resolveTaskID(taskID) + + var taskLog models.TaskLog + query := database.DB.Where("task_id = ?", taskID) + if specificLogID != "" { + query = query.Where("id = ?", specificLogID) + } + res := query.Order("created_at DESC").Limit(1).Find(&taskLog) + if res.Error != nil || res.RowsAffected == 0 { + if specificLogID != "" { + fmt.Printf("找不到任务 [%s] 指定日志ID [%s] 的记录。\n", taskID, specificLogID) + } else { + fmt.Printf("找不到任务 [%s] 的任何执行记录。\n", taskID) + } + return + } + + var task models.Task + database.DB.Where("id = ?", taskID).Limit(1).Find(&task) + taskName := taskID + if task.Name != "" { + taskName = task.Name + } + + statusText := "运行中" + switch taskLog.Status { + case constant.TaskStatusSuccess: + statusText = "成功" + case constant.TaskStatusFailed: + statusText = "失败" + case constant.TaskStatusTimeout: + statusText = "超时" + case constant.TaskStatusCancelled: + statusText = "已取消" + } + + fmt.Println("====================================================================================================") + fmt.Printf("任务名称: %s (ID: %s)\n", taskName, taskID) + fmt.Printf("日志记录: %s\n", taskLog.ID) + fmt.Printf("执行命令: %s\n", string(taskLog.Command)) + fmt.Printf("最终状态: %s (耗时: %d 毫秒, 退出码: %d)\n", statusText, taskLog.Duration, taskLog.ExitCode) + if taskLog.StartTime != nil { + fmt.Printf("开始时间: %s\n", taskLog.StartTime.Time().Format("2006-01-02 15:04:05")) + } + fmt.Println("----------------------------------------------------------------------------------------------------") + fmt.Println("[日志输出内容]") + + // 解压 + decompressed, err := utils.DecompressFromBase64(string(taskLog.Output)) + if err != nil { + fmt.Printf("[无法解压日志输出: %v]\n", err) + } else { + // 清理多余回车和终端 ANSI 转义字符 + cleanText := strings.ReplaceAll(decompressed, "\r\n", "\n") + cleanText = clibase.AnsiRegex.ReplaceAllString(cleanText, "") + fmt.Println(strings.TrimSpace(cleanText)) + } + + if string(taskLog.Error) != "" { + fmt.Println("\n[系统捕获异常]") + fmt.Println(string(taskLog.Error)) + } + fmt.Println("====================================================================================================") +} + +func runHistory(args []string) { + fs := flag.NewFlagSet("history", flag.ExitOnError) + limitPtr := fs.Int("limit", 10, "展示的最近历史记录条数") + + fs.Usage = func() { + clibase.PrintSubCommandUsage("任务池任务执行历史查看工具", "taskpool task history <任务ID/名称/repo> [参数]", " taskpool task history a1b2c3d4\n taskpool task history repo\n taskpool task history repo -limit 20", fs) + } + + if err := fs.Parse(args); err != nil { + return + } + + parsedArgs := fs.Args() + if len(parsedArgs) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标任务ID。\n") + fs.Usage() + return + } + taskID := parsedArgs[0] + + clibase.InitContext(false) + taskID = resolveTaskID(taskID) + + var task models.Task + database.DB.Where("id = ?", taskID).Limit(1).Find(&task) + taskName := taskID + if task.Name != "" { + taskName = task.Name + } + + var logs []models.TaskLog + database.DB.Where("task_id = ?", taskID).Order("created_at DESC").Limit(*limitPtr).Find(&logs) + + fmt.Println("====================================================================================================") + fmt.Printf("任务流水: %s (ID: %s) 的近期执行记录 (最多展示 %d 条)\n", taskName, taskID, *limitPtr) + fmt.Println("----------------------------------------------------------------------------------------------------") + fmt.Printf("%-20s | %-8s | %-6s | %-12s | %-20s\n", "日志ID", "状态", "退出码", "耗时", "开始时间") + fmt.Println("----------------------------------------------------------------------------------------------------") + + if len(logs) == 0 { + fmt.Println("未查询到任何历史执行记录。") + } else { + for _, l := range logs { + statusText := "运行中" + switch l.Status { + case constant.TaskStatusSuccess: + statusText = "成功" + case constant.TaskStatusFailed: + statusText = "失败" + case constant.TaskStatusTimeout: + statusText = "超时" + case constant.TaskStatusCancelled: + statusText = "已取消" + } + + startStr := "-" + if l.StartTime != nil { + startStr = l.StartTime.Time().Format("2006-01-02 15:04:05") + } + durationStr := fmt.Sprintf("%d ms", l.Duration) + + fmt.Printf("%-20s | %-8s | %-6d | %-12s | %-20s\n", l.ID, statusText, l.ExitCode, durationStr, startStr) + } + } + fmt.Println("====================================================================================================") + fmt.Printf("提示: 结合命令 'taskpool task status %s <日志ID>' 查看特定历史日志内容。\n", taskID) +} diff --git a/cmd/version/version.go b/cmd/version/version.go new file mode 100644 index 0000000..abb062b --- /dev/null +++ b/cmd/version/version.go @@ -0,0 +1,11 @@ +package version + +import ( + "fmt" + + "github.com/engigu/taskpool/internal/constant" +) + +func Run(args []string) { + fmt.Printf("taskpool %s (Build time: %s)\n", constant.Version, constant.BuildTime) +} diff --git a/cmd/webui/webui.go b/cmd/webui/webui.go new file mode 100644 index 0000000..2d5b30b --- /dev/null +++ b/cmd/webui/webui.go @@ -0,0 +1,132 @@ +package webui + +import ( + "fmt" + "os" + "strings" + + "github.com/engigu/taskpool/cmd/clibase" + "github.com/engigu/taskpool/internal/services" +) + +func printMainHelp() { + fmt.Fprintf(os.Stderr, "\n任务池 WebUI 命令行管理工具\n\n") + fmt.Fprintf(os.Stderr, "用法:\n") + fmt.Fprintf(os.Stderr, " taskpool webui <子命令> [参数]\n\n") + fmt.Fprintf(os.Stderr, "可用子命令:\n") + fmt.Fprintf(os.Stderr, " list 列出当前安装的所有前端资源包\n") + fmt.Fprintf(os.Stderr, " set 设置激活指定的 WebUI\n") + fmt.Fprintf(os.Stderr, " reset 一键回退到系统默认的内置 WebUI\n") + fmt.Fprintf(os.Stderr, " delete 删除指定的 WebUI 资源包\n\n") +} + +func Run(args []string) { + if len(args) == 0 || args[0] == "-h" || args[0] == "--help" { + printMainHelp() + return + } + + subCommand := args[0] + subArgs := args[1:] + + switch subCommand { + case "list": + runList(subArgs) + case "set": + runSet(subArgs) + case "reset": + runReset(subArgs) + case "delete": + runDelete(subArgs) + default: + fmt.Fprintf(os.Stderr, "未知子命令: %s\n", subCommand) + printMainHelp() + } +} + +func initServices() *services.WebUIService { + clibase.InitContext(false) + settingsService := services.NewSettingsService() + return services.NewWebUIService(settingsService) +} + +func runList(args []string) { + svc := initServices() + list, err := svc.GetWebUIs() + if err != nil { + fmt.Printf(">> 获取WebUI列表失败: %v\n", err) + return + } + + settingsService := services.NewSettingsService() + activeWebUI := settingsService.Get("site", "active_webui") + if activeWebUI == "" { + activeWebUI = "default" + } + + fmt.Println(strings.Repeat("=", 100)) + fmt.Printf("%s | %s | %s | %s | %s\n", + clibase.VisualFormat("名称", 20), + clibase.VisualFormat("版本", 12), + clibase.VisualFormat("作者", 15), + clibase.VisualFormat("状态", 10), + clibase.VisualFormat("描述", 30), + ) + fmt.Println(strings.Repeat("-", 100)) + + for _, w := range list { + status := "-" + if w.Name == activeWebUI { + status = "使用中" + } + + fmt.Printf("%s | %s | %s | %s | %s\n", + clibase.VisualFormat(w.Name, 20), + clibase.VisualFormat(w.Version, 12), + clibase.VisualFormat(w.Author, 15), + clibase.VisualFormat(status, 10), + clibase.VisualFormat(w.Description, 30), + ) + } + fmt.Println(strings.Repeat("=", 100)) +} + +func runSet(args []string) { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: taskpool webui set \n") + return + } + name := args[0] + svc := initServices() + err := svc.SetActiveWebUI(name) + if err != nil { + fmt.Printf(">> 设置激活WebUI失败: %v\n", err) + return + } + fmt.Printf(">> 成功激活 WebUI: %s\n", name) +} + +func runReset(args []string) { + svc := initServices() + err := svc.SetActiveWebUI("default") + if err != nil { + fmt.Printf(">> 回退默认WebUI失败: %v\n", err) + return + } + fmt.Println(">> 成功回退到内置默认 WebUI") +} + +func runDelete(args []string) { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "错误: 缺少目标 WebUI 名称。\n用法: taskpool webui delete \n") + return + } + name := args[0] + svc := initServices() + err := svc.DeleteWebUI(name) + if err != nil { + fmt.Printf(">> 删除WebUI失败: %v\n", err) + return + } + fmt.Printf(">> 成功删除 WebUI: %s\n", name) +} diff --git a/configs/config.example.ini b/configs/config.example.ini new file mode 100644 index 0000000..45c4c46 --- /dev/null +++ b/configs/config.example.ini @@ -0,0 +1,41 @@ +[server] +# 服务监听端口 +port = 8052 +# 服务监听地址 +host = 0.0.0.0 +# URL前缀,例如 /taskpool,留空则无前缀 +# 配置后:前端路径为 /taskpool/*,后端API路径为 /taskpool/api/v1/* +url_prefix = +# 全局会话 Cookie 名称 +cookie_name = BHToken + +[database] +# 数据库类型: sqlite, mysql, postgres +type = sqlite +# 数据库连接地址 (mysql/postgres) +host = localhost +# 数据库连接端口 (mysql: 3306, postgres: 5432) +port = 3306 +# 数据库用户名 +user = root +# 数据库密码 +password = +# 数据库名称 +dbname = taskpool +# 数据库文件路径 (仅 sqlite) +path = data/taskpool.db +# 数据库 DSN (仅 mysql/postgres, 如果设置则优先使用。注意:需同时将 type 设置为 mysql 或 postgres) +# 例如 (MySQL): user:password@unix(/var/run/mysqld/mysqld.sock)/dbname?charset=utf8mb4&parseTime=True&loc=Local +# 例如 (Postgres): postgres://user:password@localhost:5432/dbname?sslmode=disable +dsn = +# 表前缀 +table_prefix = taskpool_ +# SSL 模式 (仅 mysql/postgres): postgres 支持 disable/require/verify-ca/verify-full; mysql 支持 true/skip-verify +# ssl_mode = disable + +[security] +# JWT 密钥。留空则在首次启动时自动生成并保存到数据库设置中。 +# 如果你手动设置了此项,它将覆盖数据库中的设置。 +secret = + + diff --git a/configs/config_child.ini b/configs/config_child.ini new file mode 100644 index 0000000..457d5fe --- /dev/null +++ b/configs/config_child.ini @@ -0,0 +1,21 @@ +[server] +port = 8055 +host = 0.0.0.0 +; url_prefix = /taskpool +message_server = http://message-nest-demo-site.qwapi.eu.org/ +pprof_enabled=true + + +[database] +; type = sqlite +type = mysql +host = app.engigu.cn +port = 33066 +user = root +password = Gq19940507+****+ +dbname = taskpool-test2 +table_prefix = taskpool_ +; debug = true + +[security] +secret = taskpool_secret_key_change_me diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e2df28a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,32 @@ +services: + taskpool-dev: + build: + context: . + dockerfile: docker/Dockerfile.dev + container_name: taskpool-dev + ports: + - "8052:8052" + - "5173:5173" + volumes: + - .:/app:Z + - taskpool-envs:/app/envs + - taskpool-node-modules:/app/web/node_modules + - go-path:/go + - npm-cache:/var/cache/npm + environment: + - TZ=Asia/Shanghai + - DEV_UID=${DEV_UID:-1000} + - DEV_GID=${DEV_GID:-1000} + - MISE_YES=1 # Auto-confirm mise prompts + stdin_open: true + tty: true + +volumes: + taskpool-envs: + name: taskpool_dev_envs + taskpool-node-modules: + name: taskpool_dev_node_modules + go-path: + name: taskpool_dev_go_path + npm-cache: + name: taskpool_dev_npm_cache diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml new file mode 100644 index 0000000..2c33a95 --- /dev/null +++ b/docker-compose.ghcr.yml @@ -0,0 +1,26 @@ + + +services: + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./configs:/app/configs + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + # 以下环境变量可覆盖配置文件(可选) + # - BH_SERVER_PORT=8052 + # - BH_SERVER_HOST=0.0.0.0 + # - BH_DB_TYPE=mysql + # - BH_DB_HOST=localhost + # - BH_DB_PORT=3306 + # - BH_DB_USER=root + # - BH_DB_PASSWORD=password + # - BH_DB_NAME=taskpool + # - BH_DB_TABLE_PREFIX=taskpool_ + # - BH_SECRET=your_secret_key + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9906a0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + # Frontend - React Web UI + frontend: + image: git.viaeon.com/admin/taskpool-react:latest + ports: + - "3000:80" + depends_on: + - backend + restart: unless-stopped + + # Backend - Go API Server + backend: + image: git.viaeon.com/admin/taskpool:latest + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./configs:/app/configs + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=sqlite + - BH_DB_PATH=/app/data/taskpool.db + restart: unless-stopped + + # Redis (可选 - 用于缓存和队列) + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + restart: unless-stopped + profiles: + - redis # 使用 --profile redis 启用 + +volumes: + redis_data: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ed388ef --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,180 @@ +ARG BASE_TAG=base +# ================================ +# Stage 1: Build frontend +# ================================ +FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder + +WORKDIR /app/web + +# Copy package files +COPY web/package*.json ./ + +# Install dependencies using cache mount for npm +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +# Copy frontend source +COPY web/ ./ + +# Build frontend +RUN npm run build + +# ================================ +# Stage 0: Generate build info +# ================================ +FROM alpine:3.19 AS build-info + +ARG VERSION +ARG BUILD_TIME + +# 生成版本信息文件,确保主服务和 Agent 使用相同的值(使用东八区时间) +RUN VERSION_VAL="${VERSION:-dev-$(TZ=Asia/Shanghai date '+%Y%m%d%H%M%S')}" && \ + BUILD_TIME_VAL="${BUILD_TIME:-$(TZ=Asia/Shanghai date '+%Y-%m-%d %H:%M:%S')}" && \ + mkdir -p /build-info && \ + echo "${VERSION_VAL}" > /build-info/version.txt && \ + echo "${BUILD_TIME_VAL}" > /build-info/build_time.txt + +# ================================ +# Stage 2: Build backend +# ================================ +FROM --platform=$BUILDPLATFORM golang:1.26 AS backend-builder + +ARG TARGETOS +ARG TARGETARCH + + +WORKDIR /app + +# Copy build info +COPY --from=build-info /build-info /build-info + +# Go mod files +COPY go.mod go.sum ./ +RUN go env -w GOPROXY=https://goproxy.cn,direct +# Using Go build cache mount can significantly speed up consecutive builds +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +# Copy backend source +COPY . . + +# Copy frontend dist (needed for embedding) +COPY --from=frontend-builder /app/web/dist ./internal/static/dist + +# Generate Swagger (Optional: better to commit docs and only copy, but if needed, install once) +# If openapi_docs exists, just use it, otherwise generate. +# Here we avoid go run @latest by copying it if you have it locally. +# If you don't, we install it once to the image. +RUN go install github.com/swaggo/swag/cmd/swag@latest +RUN swag init -g main.go -o ./openapi_docs + +# Build Go binary using cache mounts for faster builds +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + VERSION_VAL=$(cat /build-info/version.txt) && \ + BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w -X github.com/engigu/taskpool/internal/constant.Version=${VERSION_VAL} -X 'github.com/engigu/taskpool/internal/constant.BuildTime=${BUILD_TIME_VAL}'" \ + -o taskpool . + +# ================================ +# Stage 3: Build Agent (all platforms) +# ================================ +FROM --platform=$BUILDPLATFORM golang:1.26 AS agent-builder + + +WORKDIR /app + +# Copy build info +COPY --from=build-info /build-info /build-info + +# Using Go build cache mount +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +# Copy source needed for agent +COPY internal/ ./internal/ +COPY agent/ ./agent/ + +# Build agent for all platforms and package as tar.gz +WORKDIR /app/agent + +# Build agent with parallel-aware logic or just cache mount +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + VERSION_VAL=$(cat /build-info/version.txt) && \ + BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \ + LDFLAGS="-s -w -X 'main.Version=${VERSION_VAL}' -X 'main.BuildTime=${BUILD_TIME_VAL}'" && \ + mkdir -p /opt/agent && \ + echo "${VERSION_VAL}" > /opt/agent/version.txt && \ + # Helper to build and compress + build_agent() { \ + local os=$1; local arch=$2; local suffix=$3; \ + local tmpdir="build-$os-$arch"; \ + mkdir -p $tmpdir && cp config.example.ini $tmpdir/ && \ + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -ldflags="${LDFLAGS}" -o $tmpdir/taskpool-agent$suffix . && \ + (cd $tmpdir && tar -czvf /opt/agent/taskpool-agent-$os-$arch.tar.gz taskpool-agent$suffix config.example.ini) && \ + rm -rf $tmpdir; \ + } ; \ + build_agent linux amd64 "" & pid1=$!; \ + build_agent linux arm64 "" & pid2=$!; \ + build_agent android arm64 "" & pid3=$!; \ + build_agent darwin amd64 "" & pid4=$!; \ + build_agent darwin arm64 "" & pid5=$!; \ + wait $pid1 && wait $pid2 && wait $pid3 && wait $pid4 && wait $pid5 && \ + echo "Agent build completed for all platforms" +# ================================ +# Stage 4: Final image +# ================================ +ARG BASE_TAG +FROM ghcr.io/engigu/taskpool:${BASE_TAG} + +ENV TZ=Asia/Shanghai +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV MISE_DATA_DIR=/app/envs/mise +ENV MISE_CONFIG_DIR=/app/envs/mise +ENV PATH="/app/envs/mise/shims:/app/envs/mise/bin:$PATH" + +# # 安装必要系统工具 + Node + Python +# RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ +# && sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ +# && echo "${TZ}" > /etc/timezone \ +# && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ +# && apt-get update \ +# && apt-get install -y --no-install-recommends \ +# tzdata git gcc curl wget vim nodejs htop npm python3 python3-venv python3-pip \ +# && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy Go binary +COPY --from=backend-builder /app/taskpool . + +# Copy frontend assets to /www/taskpool (No embed in Docker) +COPY --from=frontend-builder /app/web/dist /www/taskpool + +# Copy configs and entrypoint +COPY --from=backend-builder /app/configs ./configs +COPY --from=backend-builder /app/example ./example +COPY builtin/ /www/builtin +COPY docker/docker-entrypoint.sh . + + + +# Copy agent binaries to /opt/agent +COPY --from=agent-builder /opt/agent /opt/agent + +COPY docker/mise-hook.sh /etc/profile.d/mise-hook.sh + +RUN chmod +x docker-entrypoint.sh \ + && cat /etc/profile.d/mise-hook.sh >> /etc/bash.bashrc \ + && echo "set encoding=utf-8" >> /etc/vim/vimrc \ + && ln -sf /app/taskpool /usr/local/bin/taskpool + +ENV MISE_TRUSTED_CONFIG_PATHS=/app/data/scripts + +EXPOSE 8052 + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/docker/Dockerfile.alpine.base b/docker/Dockerfile.alpine.base new file mode 100644 index 0000000..496b766 --- /dev/null +++ b/docker/Dockerfile.alpine.base @@ -0,0 +1,26 @@ +FROM alpine:3.19 + +ENV TZ=Asia/Shanghai +ENV NODE_VERSION=23.11.1 +ENV PYTHON_VERSION=3.13.12 +ENV MISE_DATA_DIR=/opt/mise-base +ENV MISE_CONFIG_DIR=/opt/mise-base +ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH" + +# 安装必要系统工具 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \ + && apk add --no-cache \ + tzdata git bash curl wget vim htop ca-certificates rsync openssh-client \ + && BUILD_DEPS="build-base libffi-dev openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev ncurses-dev xz-dev" \ + && apk add --no-cache $BUILD_DEPS \ + && curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ + && MISE_DATA_DIR=/opt/mise-base MISE_CONFIG_DIR=/opt/mise-base MISE_HTTP_TIMEOUT=300 \ + PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \ + mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \ + && mise prune -- -f \ + && apk del $BUILD_DEPS \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && cp /usr/share/zoneinfo/${TZ} /etc/localtime \ + && echo "${TZ}" > /etc/timezone \ + && rm -rf /var/cache/apk/* \ + && rm -rf /root/.cache/mise /root/.cache/pip /opt/mise-base/cache/* diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 0000000..b7306c5 --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,29 @@ +FROM debian:bookworm-slim + +ENV TZ=Asia/Shanghai +ENV NODE_VERSION=23.11.1 +ENV PYTHON_VERSION=3.13.12 +ENV MISE_DATA_DIR=/opt/mise-base +ENV MISE_CONFIG_DIR=/opt/mise-base +ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH" + +# 安装必要系统工具 +RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && echo "${TZ}" > /etc/timezone \ + && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + tzdata git curl wget vim htop ca-certificates rsync openssh-client \ + && BUILD_DEPS="gcc build-essential libssl-dev zlib1g-dev libbz2-dev \ + libreadline-dev libsqlite3-dev libncursesw5-dev \ + xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" \ + && apt-get install -y --no-install-recommends $BUILD_DEPS \ + && curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ + && MISE_DATA_DIR=/opt/mise-base MISE_CONFIG_DIR=/opt/mise-base MISE_HTTP_TIMEOUT=300 \ + PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \ + mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \ + && mise prune -- -f \ + && apt-get purge -y --auto-remove $BUILD_DEPS \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /root/.cache/mise /root/.cache/pip /opt/mise-base/cache/* diff --git a/docker/Dockerfile.debian13.base b/docker/Dockerfile.debian13.base new file mode 100644 index 0000000..6e0c659 --- /dev/null +++ b/docker/Dockerfile.debian13.base @@ -0,0 +1,30 @@ +FROM debian:trixie-slim + +ENV TZ=Asia/Shanghai +ENV NODE_VERSION=23.11.1 +ENV PYTHON_VERSION=3.13.12 +ENV MISE_DATA_DIR=/opt/mise-base +ENV MISE_CONFIG_DIR=/opt/mise-base +ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH" + +# 安装必要系统工具 +RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && echo "${TZ}" > /etc/timezone \ + && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + tzdata git curl wget vim htop ca-certificates rsync openssh-client \ + && BUILD_DEPS="gcc build-essential libssl-dev zlib1g-dev libbz2-dev \ + libreadline-dev libsqlite3-dev libncursesw5-dev \ + xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" \ + && apt-get install -y --no-install-recommends $BUILD_DEPS \ + && curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ + && MISE_DATA_DIR=/opt/mise-base MISE_CONFIG_DIR=/opt/mise-base MISE_HTTP_TIMEOUT=300 \ + PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \ + mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \ + && mise prune -- -f \ + && apt-get purge -y --auto-remove $BUILD_DEPS \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /root/.cache/mise /root/.cache/pip /opt/mise-base/cache/* diff --git a/docker/Dockerfile.debian13.minimal b/docker/Dockerfile.debian13.minimal new file mode 100644 index 0000000..a32b6cf --- /dev/null +++ b/docker/Dockerfile.debian13.minimal @@ -0,0 +1,19 @@ +FROM debian:trixie-slim + +ENV TZ=Asia/Shanghai +ENV MISE_DATA_DIR=/opt/mise-base +ENV MISE_CONFIG_DIR=/opt/mise-base +ENV PATH="/opt/mise-base/shims:/opt/mise-base/bin:$PATH" + +# 安装必要系统工具并仅预装 mise +RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && echo "${TZ}" > /etc/timezone \ + && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + tzdata git curl wget vim htop ca-certificates rsync openssh-client \ + && curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ + && mkdir -p /opt/mise-base \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /root/.cache/mise /opt/mise-base/cache/* diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..84cadc5 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,53 @@ +FROM golang:1.26-trixie + +ENV TZ=Asia/Shanghai +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +ENV NODE_VERSION=23.11.1 +ENV PYTHON_VERSION=3.13.12 + +RUN sed -i 's@deb.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's@security.debian.org@mirrors.tuna.tsinghua.edu.cn@g' /etc/apt/sources.list.d/debian.sources \ + && echo "${TZ}" > /etc/timezone \ + && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + sudo gosu tzdata git curl wget vim htop ca-certificates rsync openssh-client \ + && BUILD_DEPS="gcc build-essential libssl-dev zlib1g-dev libbz2-dev \ + libreadline-dev libsqlite3-dev libncursesw5-dev \ + xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev" \ + && apt-get install -y --no-install-recommends $BUILD_DEPS \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/mise-hook.sh /etc/profile.d/mise-hook.sh +RUN cat /etc/profile.d/mise-hook.sh >> /etc/bash.bashrc + +RUN go env -w GOPROXY=https://goproxy.cn,direct + +ENV MISE_GLOBAL_DIR=/opt/mise-dev +ENV PATH="/opt/mise-dev/shims:/opt/mise-dev/bin:$PATH" +RUN curl https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh \ + && MISE_DATA_DIR=/opt/mise-dev MISE_CONFIG_DIR=/opt/mise-dev MISE_HTTP_TIMEOUT=300 \ + PYTHON_BUILD_MIRROR_URL=https://pyenv.pages.dev/api/mirror/binaries \ + mise use -g node@${NODE_VERSION} python@${PYTHON_VERSION} \ + && mise prune -- -f + +ENV MISE_DATA_DIR=/app/envs/mise +ENV MISE_CONFIG_DIR=/app/envs/mise +ENV PATH="/app/envs/mise/shims:/app/envs/mise/bin:$PATH" + +ENV NPM_CONFIG_CACHE=/var/cache/npm + +COPY docker/docker-entrypoint-dev.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint-dev.sh + +WORKDIR /app + +ENV MISE_TRUSTED_CONFIG_PATHS=/app/data/scripts + +EXPOSE 8052 5173 + +ENTRYPOINT ["docker-entrypoint-dev.sh"] + +CMD ["make", "dev"] diff --git a/docker/Dockerfile.minimal b/docker/Dockerfile.minimal new file mode 100644 index 0000000..d4263ff --- /dev/null +++ b/docker/Dockerfile.minimal @@ -0,0 +1,147 @@ +ARG BASE_TAG=base-minimal +# ================================ +# Stage 1: Build frontend +# ================================ +FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder + +WORKDIR /app/web + +# Copy package files +COPY web/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy frontend source +COPY web/ ./ + +# Build frontend +RUN npm run build + +# ================================ +# Stage 0: Generate build info +# ================================ +FROM alpine:3.19 AS build-info + +ARG VERSION +ARG BUILD_TIME + +# 生成版本信息文件 +RUN VERSION_VAL="${VERSION:-dev-$(TZ=Asia/Shanghai date '+%Y%m%d%H%M%S')}" && \ + BUILD_TIME_VAL="${BUILD_TIME:-$(TZ=Asia/Shanghai date '+%Y-%m-%d %H:%M:%S')}" && \ + mkdir -p /build-info && \ + echo "${VERSION_VAL}" > /build-info/version.txt && \ + echo "${BUILD_TIME_VAL}" > /build-info/build_time.txt + +# ================================ +# Stage 2: Build backend +# ================================ +FROM --platform=$BUILDPLATFORM golang:1.26 AS backend-builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /app + +# Copy build info +COPY --from=build-info /build-info /build-info + +# Go mod files +COPY go.mod go.sum ./ +RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download + +# Copy backend source +COPY . . + +# Copy frontend dist (needed for embedding) +COPY --from=frontend-builder /app/web/dist ./internal/static/dist + +# Generate Swagger +RUN go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o ./openapi_docs + +# Build Go binary +RUN VERSION_VAL=$(cat /build-info/version.txt) && \ + BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w -X github.com/engigu/taskpool/internal/constant.Version=${VERSION_VAL} -X 'github.com/engigu/taskpool/internal/constant.BuildTime=${BUILD_TIME_VAL}'" \ + -o taskpool . + +# ================================ +# Stage 3: Build Agent (all platforms) +# ================================ +FROM --platform=$BUILDPLATFORM golang:1.26 AS agent-builder + +WORKDIR /app + +# Copy build info +COPY --from=build-info /build-info /build-info + +# Go mod files +COPY go.mod go.sum ./ +RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download + +# Copy source needed for agent +COPY internal/ ./internal/ +COPY agent/ ./agent/ + +# Build agent for all platforms and package as tar.gz +WORKDIR /app/agent + +RUN VERSION_VAL=$(cat /build-info/version.txt) && \ + BUILD_TIME_VAL=$(cat /build-info/build_time.txt) && \ + LDFLAGS="-s -w -X 'main.Version=${VERSION_VAL}' -X 'main.BuildTime=${BUILD_TIME_VAL}'" && \ + mkdir -p /opt/agent && \ + echo "${VERSION_VAL}" > /opt/agent/version.txt && \ + build_agent() { \ + local os=$1; local arch=$2; local suffix=$3; \ + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -ldflags="${LDFLAGS}" -o taskpool-agent$suffix . && \ + tar -czvf /opt/agent/taskpool-agent-$os-$arch.tar.gz taskpool-agent$suffix config.example.ini && \ + rm taskpool-agent$suffix; \ + } && \ + build_agent linux amd64 "" && \ + build_agent linux arm64 "" && \ + build_agent android arm64 "" && \ + build_agent darwin amd64 "" && \ + build_agent darwin arm64 "" + +# ================================ +# Stage 4: Final image +# ================================ +FROM ghcr.io/engigu/taskpool:${BASE_TAG} + +ENV TZ=Asia/Shanghai +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV MISE_DATA_DIR=/app/envs/mise +ENV MISE_CONFIG_DIR=/app/envs/mise +ENV PATH="/app/envs/mise/shims:/app/envs/mise/bin:$PATH" + +WORKDIR /app + +# Copy Go binary +COPY --from=backend-builder /app/taskpool . + +# Copy frontend assets +COPY --from=frontend-builder /app/web/dist /www/taskpool + +# Copy configs and entrypoint +COPY --from=backend-builder /app/configs ./configs +COPY --from=backend-builder /app/example ./example +COPY builtin/ /www/builtin +COPY docker/docker-entrypoint.minimal.sh ./docker-entrypoint.sh + +# Copy agent binaries +COPY --from=agent-builder /opt/agent /opt/agent + +COPY docker/mise-hook.sh /etc/profile.d/mise-hook.sh + +RUN chmod +x docker-entrypoint.sh \ + && cat /etc/profile.d/mise-hook.sh >> /etc/bash.bashrc \ + && echo "set encoding=utf-8" >> /etc/vim/vimrc \ + && ln -sf /app/taskpool /usr/local/bin/taskpool + +ENV MISE_TRUSTED_CONFIG_PATHS=/app/data/scripts + +EXPOSE 8052 + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/docker/docker-entrypoint-dev.sh b/docker/docker-entrypoint-dev.sh new file mode 100644 index 0000000..f81f29d --- /dev/null +++ b/docker/docker-entrypoint-dev.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -e +export LANG=C.UTF-8 +export LC_ALL=C.UTF-8 + +export MISE_HIDE_UPDATE_WARNING=1 + +COLOR_PREFIX="\033[1;36m[Entrypoint]\033[0m" +log() { + printf "${COLOR_PREFIX} %s\n" "$1" +} + +ensure_cache_ownership() { + local dir="$1" + local target_uid="$2" + local target_gid="$3" + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + chown "$target_uid:$target_gid" "$dir" + return + fi + local current_uid=$(stat -c '%u' "$dir") + local dirty_file=$(find "$dir" -mindepth 1 ! -uid "$target_uid" -print -quit 2>/dev/null) + if [ "$current_uid" == "$target_uid" ] && [ -z "$dirty_file" ]; then + log "Cache hit: $dir owned by $target_uid. Reusing." + else + if [ -n "$dirty_file" ]; then + log "Dirty cache detected in $dir (Found root-owned files like $dirty_file)." + else + log "User changed ($current_uid -> $target_uid). Resetting cache in $dir..." + fi + + log "Nuking directory to ensure clean state..." + + find "$dir" -mindepth 1 -delete 2>/dev/null || rm -rf "$dir"/* + + chown "$target_uid:$target_gid" "$dir" + log "Cache reset complete. Ownership transferred to $target_uid." + fi +} + +log "Initializing Development Environment..." + +DEV_UID=${DEV_UID:-1000} +DEV_GID=${DEV_GID:-1000} + +EXISTING_USER=$(getent passwd "$DEV_UID" | cut -d: -f1 | head -n 1) + +if [ -n "$EXISTING_USER" ]; then + TARGET_USER="$EXISTING_USER" + log "UID $DEV_UID already exists as user '$TARGET_USER'. Using existing user." +else + TARGET_USER="devuser" + log "Creating user '$TARGET_USER' (UID: $DEV_UID, GID: $DEV_GID)..." + groupadd -o -g "$DEV_GID" devgroup + useradd -o -u "$DEV_UID" -g "$DEV_GID" -m -s /bin/bash "$TARGET_USER" +fi + +if [ "$TARGET_USER" != "root" ]; then + echo "$TARGET_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$TARGET_USER + chmod 0440 /etc/sudoers.d/$TARGET_USER +fi + +export npm_config_cache=/var/cache/npm +mkdir -p /var/cache/npm /app/web/node_modules +chown "$DEV_UID:$DEV_GID" /var/cache/npm /app/web/node_modules + +if [ "$DEV_UID" != "0" ]; then + ensure_cache_ownership "/app/envs" "$DEV_UID" "$DEV_GID" + ensure_cache_ownership "/app/web/node_modules" "$DEV_UID" "$DEV_GID" + ensure_cache_ownership "/var/cache/npm" "$DEV_UID" "$DEV_GID" + ensure_cache_ownership "/go" "$DEV_UID" "$DEV_GID" +fi + +MISE_DIR="/app/envs/mise" +mkdir -p "$MISE_DIR" +log "Syncing mise environment from base..." +rsync -a --chown="$DEV_UID:$DEV_GID" --ignore-existing /opt/mise-dev/ "$MISE_DIR" || true +log "Mise environment synced" + +export MISE_DATA_DIR="$MISE_DIR" +export MISE_CONFIG_DIR="$MISE_DIR" +export PATH="$MISE_DIR/shims:$MISE_DIR/bin:$PATH" + +export PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple} + +log "mise version: $(mise --version 2>/dev/null | head -n 1)" +log "python: $(python --version 2>&1 | head -n 1) at $(which python)" +log "node: $(node --version 2>&1 | head -n 1) at $(which node)" +log "npm: $(npm --version 2>&1 | head -n 1) at $(which npm)" + +log "Environment ready! Starting command as user '$TARGET_USER' (UID: $DEV_UID)..." +exec gosu "$DEV_UID:$DEV_GID" "$@" diff --git a/docker/docker-entrypoint.minimal.sh b/docker/docker-entrypoint.minimal.sh new file mode 100644 index 0000000..b5d5c9f --- /dev/null +++ b/docker/docker-entrypoint.minimal.sh @@ -0,0 +1,80 @@ +#!/bin/sh +set -e +export LANG=C.UTF-8 +export LC_ALL=C.UTF-8 + +export MISE_HIDE_UPDATE_WARNING=1 + +# 日志输出格式 +COLOR_PREFIX="\033[1;36m[Entrypoint]\033[0m" +log() { + printf "${COLOR_PREFIX} %s\n" "$1" +} + +MISE_DIR="/app/envs/mise" + +log "Starting environment initialization (Minimal Mode)..." + +# ============================ +# 创建基础目录 +# ============================ +mkdir -p \ + /app/data \ + /app/data/scripts \ + /app/configs \ + /app/envs + +if [ -d "/app/example" ]; then + mkdir -p /app/data/scripts/example + rsync -a --ignore-existing /app/example/ /app/data/scripts/example/ || true + log "Example scripts synced to /app/data/scripts/example" +else + log "No example directory found, skipping example sync" +fi + +# ============================ +# Mise 环境初始化 +# ============================ +mkdir -p "$MISE_DIR" +if [ -d "/opt/mise-base" ]; then + log "Syncing mise environment from base..." + rsync -a --ignore-existing /opt/mise-base/ "$MISE_DIR/" || true + log "Mise environment synced" +else + log "No base mise environment found, skipping sync" +fi + +# ============================ +# 环境变量注入 +# ============================ +export MISE_DATA_DIR="$MISE_DIR" +export MISE_CONFIG_DIR="$MISE_DIR" +export PATH="$MISE_DIR/shims:$MISE_DIR/bin:$PATH" + +# 默认启用 Python 镜像源 +export PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple} + +# Node 内存限制 +export NODE_OPTIONS="--max-old-space-size=256" +export PYTHONPATH=/app/data/scripts:$PYTHONPATH + +# ============================ +# 打印确认 +# ============================ +log "mise version: $(mise --version 2>/dev/null | head -n 1)" +[ -x "$(command -v python)" ] && log "python: $(python --version 2>&1 | head -n 1) at $(which python)" || log "python: not installed" +[ -x "$(command -v node)" ] && log "node: $(node --version 2>&1 | head -n 1) at $(which node)" || log "node: not installed" +[ -x "$(command -v npm)" ] && log "npm: $(npm --version 2>&1 | head -n 1) at $(which npm)" || log "npm: not installed" + +# ============================ +# 将 taskpool 注册到全局命令 +# ============================ +ln -sf /app/taskpool /usr/local/bin/taskpool + +# ============================ +# 启动应用 +# ============================ +printf "\n\033[1;32m>>> Environment setup complete. Starting TaskPool Server...\033[0m\n\n" + +cd /app +exec taskpool server diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..e68db5e --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,91 @@ +#!/bin/sh +set -e +export LANG=C.UTF-8 +export LC_ALL=C.UTF-8 + +export MISE_HIDE_UPDATE_WARNING=1 + +# 日志输出格式 +COLOR_PREFIX="\033[1;36m[Entrypoint]\033[0m" +log() { + printf "${COLOR_PREFIX} %s\n" "$1" +} + +MISE_DIR="/app/envs/mise" + +log "Starting environment initialization..." + +# ============================ +# 创建基础目录 +# ============================ +mkdir -p \ + /app/data \ + /app/data/scripts \ + /app/configs \ + /app/envs + +if [ -d "/app/example" ]; then + mkdir -p /app/data/scripts/example + rsync -a --ignore-existing /app/example/ /app/data/scripts/example/ || true + log "Example scripts synced to /app/data/scripts/example" +else + log "No example directory found, skipping example sync" +fi + +# ============================ +# Mise 环境初始化 +# ============================ +# 始终尝试同步基础环境(以补充用户挂载卷中可能缺失的文件,如 config.toml) +mkdir -p "$MISE_DIR" +if [ -d "/opt/mise-base" ]; then + log "Syncing mise environment from base..." + # 使用 rsync 同步: -a 归档模式, --ignore-existing 不覆盖已存在文件 + rsync -a --ignore-existing /opt/mise-base/ "$MISE_DIR/" || true + log "Mise environment synced" +else + log "No base mise environment found, skipping sync" +fi + +# ============================ +# 环境变量注入 +# ============================ +export MISE_DATA_DIR="$MISE_DIR" +export MISE_CONFIG_DIR="$MISE_DIR" +export PATH="$MISE_DIR/shims:$MISE_DIR/bin:$PATH" + +log "Mise PATH configured, verifying runtimes..." + +# 默认启用 Python 镜像源 +export PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple} + +# Node 内存限制 +export NODE_OPTIONS="--max-old-space-size=256" +export PYTHONPATH=/app/data/scripts:$PYTHONPATH + +# ============================ +# 打印确认 (增加超时防护,防止这里卡死) +# ============================ +log "Checking mise..." +log " - mise: $(mise --version 2>/dev/null | head -n 1 || echo "not found")" + +log "Checking python..." +log " - python: $(python --version 2>&1 | head -n 1 || echo "not found")" + +log "Checking node..." +log " - node: $(node --version 2>&1 | head -n 1 || echo "not found")" + +log "Checking npm..." +log " - npm: $(npm --version 2>&1 | head -n 1 || echo "not found")" + +# ============================ +# 将 taskpool 注册到全局命令 +# ============================ +ln -sf /app/taskpool /usr/local/bin/taskpool + +# ============================ +# 启动应用 +# ============================ +printf "\n\033[1;32m>>> Environment setup complete. Starting TaskPool Server...\033[0m\n\n" + +cd /app +exec taskpool server diff --git a/docker/mise-hook.sh b/docker/mise-hook.sh new file mode 100644 index 0000000..afcd500 --- /dev/null +++ b/docker/mise-hook.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# 面向终端的交互式 Hook 拦截器 +# 目的是为了在终端执行 node 相关任务时具有与 Go 系统底层相同的 NODE_PATH 环境 +# +# 支持以下各种执行场景: +# 1. mise exec node@23.11.1 -- node index.js (指明具体版本) +# 2. mise exec python@3 node@23 -- node index.js (混合语言注入) +# 3. mise exec node -- node index.js (显式指定环境但使用默认版本) +# 4. mise exec -- node index.js (完全隐式环境,由项目配置决定) +mise() { + if [[ "$1" == "exec" || "$1" == "x" ]]; then + local node_spec="" + # 优先从命令行参数中提取指定的 node 版本(如 node@23...) + for arg in "$@"; do + if [[ "$arg" == "--" ]]; then + break + elif [[ "$arg" == "node" || "$arg" == node@* ]]; then + node_spec="$arg" + break + fi + done + + # 如果命令行参数中没写,则尝试检测该环境下是否有已激活的默认 node + if [[ -z "$node_spec" ]]; then + if command mise which node >/dev/null 2>&1; then + node_spec="node" + fi + fi + + if [[ -n "$node_spec" ]]; then + local node_dir=$(command mise where "$node_spec" 2>/dev/null) + if [[ -n "$node_dir" ]]; then + NODE_PATH="$node_dir/lib/node_modules" command mise "$@" + return $? + fi + fi + fi + # 非 Node 场景或拦截失败,正常向下执行 + command mise "$@" +} + +# 导出此函数,供所有子环境继承使用 +export -f mise diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..de15a17 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,93 @@ +import { defineConfig } from 'vitepress' + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: 'taskpool', + description: '轻量易用的定时任务面板,支持多语言脚本、依赖管理与日志查看', + base: '/taskpool/', + lang: 'zh-CN', + head: [ + ['link', { rel: 'stylesheet', href: 'https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@400;500;700&family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap' }], + ['script', {}, `if (navigator.userAgent.indexOf('Windows') !== -1) document.documentElement.classList.add('is-windows');`] + ], + themeConfig: { + logo: '/logo.svg', + nav: [ + { text: '快速开始', link: '/guide/introduction' }, + { text: '部署指南', link: '/guide/deployment' }, + { text: 'API 文档', link: '/guide/api' } + ], + + sidebar: [ + { + text: '基础指南', + items: [ + { text: '项目介绍', link: '/guide/introduction' }, + { text: '部署说明', link: '/guide/deployment' }, + { text: '开始使用', link: '/guide/getting-started' }, + { text: 'API 文档', link: '/guide/api' } + ] + }, + { + text: '功能指南', + items: [ + { text: '数据仪表', link: '/guide/dashboard' }, + { text: '定时任务', link: '/guide/tasks' }, + { text: '远程执行', link: '/guide/agents' }, + { text: '面板互联', link: '/guide/interconnect' }, + { text: '脚本管理', link: '/guide/scripts' }, + { text: '执行历史', link: '/guide/history' }, + { text: '变量机密', link: '/guide/environments' }, + { text: '语言依赖', link: '/guide/languages' }, + { text: '终端命令', link: '/guide/terminal' }, + { text: '消息中心', link: '/guide/notify' }, + { text: '仓库同步', link: '/guide/sync' }, + { text: '命令行(CLI)', link: '/guide/cli' }, + { + text: '脚本示例', + link: '/guide/examples/', + items: [ + { text: '浏览器示例', link: '/guide/examples/browser' }, + { text: '内置库示例', link: '/guide/examples/builtin' }, + { text: 'Linux 环境依赖', link: '/guide/examples/linux-deps' } + ] + } + ] + }, + { + text: '部署配置', + items: [ + { text: '系统配置', link: '/guide/configuration' }, + { text: '前端定制(WebUI)', link: '/guide/webui' }, + { text: '反向代理', link: '/guide/nginx' } + ] + }, + { + text: '其他', + items: [ + { text: '镜像下载量', link: '/guide/package-stats' }, + { text: '更新日志', link: '/guide/changelog' }, + { text: '免责声明', link: '/guide/disclaimer' } + ] + } + ], + + socialLinks: [ + { icon: 'github', link: 'https://github.com/engigu/taskpool' } + ], + + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © 2026-present engigu' + }, + + search: { + provider: 'local' + } + }, + vite: { + ssr: { + noExternal: ['@scalar/api-reference'] + } + } +}) diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..09fb5b1 --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,11 @@ +:root { + --vp-font-family-mono: "Ubuntu Mono", "Noto Sans SC", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Courier New", monospace; + --vp-code-font-size: 14px; +} + + + +/* 仅在 Windows 下优先使用 Noto Sans SC (思源黑体),其他系统保持系统默认字体或 Inter */ +.is-windows { + --vp-font-family-base: "Inter", "Noto Sans SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..42fe9a9 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './custom.css' + +export default DefaultTheme diff --git a/docs/fetch-stats.js b/docs/fetch-stats.js new file mode 100644 index 0000000..6e57643 --- /dev/null +++ b/docs/fetch-stats.js @@ -0,0 +1,88 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +async function scrapeAll() { + const allResults = []; + const seenTags = new Set(); + let page = 1; + let reachedEnd = false; + while (!reachedEnd) { + console.log(`Fetching page ${page}...`); + const url = `https://github.com/engigu/taskpool/pkgs/container/taskpool/versions?page=${page}`; + let html; + let success = false; + for (let retry = 1; retry <= 3; retry++) { + try { + html = execSync(`curl -sL "${url}"`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 10 }); + success = true; + break; + } catch (e) { + console.error(`Failed to fetch page ${page} (attempt ${retry}/3):`, e.message); + if (retry < 3) { + console.log(`Waiting 5s before retrying...`); + await new Promise(r => setTimeout(r, 5000)); + } + } + } + if (!success) { + console.error(`Giving up on page ${page}.`); + break; + } + + const boxRows = html.split('class="Box-row"'); + if (boxRows.length <= 1) { + console.log(`No versions found on page ${page}. Stopping.`); + break; + } + + let parsedCount = 0; + for (let i = 1; i < boxRows.length; i++) { + const row = boxRows[i]; + const tagMatch = row.match(/\?tag=([^"]+)"[^>]*>([^<]+)<\/a>/); + if (!tagMatch) continue; + const tag = tagMatch[1]; + + if (seenTags.has(tag)) { + console.log(`Duplicate tag "${tag}" detected. Reached end of registry pages.`); + reachedEnd = true; + break; + } + seenTags.add(tag); + + // 只保留形如 1.1.15、1.1.15-minimal、1.1.15-debian13 以及 latest 等主版本及其不同架构/后缀版本 + if (!/^(latest|\d+\.\d+\.\d+)/.test(tag)) { + continue; + } + + const downloadsMatch = row.match(/([\d,]+)\s*Version downloads<\/span>/); + const downloads = downloadsMatch ? parseInt(downloadsMatch[1].replace(/,/g, ''), 10) : 0; + + allResults.push({ tag, downloads }); + parsedCount++; + } + + if (reachedEnd) break; + + console.log(`Parsed ${parsedCount} versions from page ${page}.`); + + // 每页保存一次,防止后面的页面超时或出错导致前面的数据丢失 + const destDir = path.join(__dirname, './data'); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + const destPath = path.join(destDir, 'pull-stats.json'); + const outputData = { + updatedAt: new Date().toISOString(), + stats: allResults + }; + fs.writeFileSync(destPath, JSON.stringify(outputData, null, 2)); + console.log(`Saved ${allResults.length} versions (up to page ${page}) to ${destPath}`); + + page++; + // Sleep 1s to avoid hitting rate limits + await new Promise(r => setTimeout(r, 1000)); + } +} + +scrapeAll(); diff --git a/docs/guide/agents.md b/docs/guide/agents.md new file mode 100644 index 0000000..52b1f42 --- /dev/null +++ b/docs/guide/agents.md @@ -0,0 +1,23 @@ +# 远程执行 (Agents) + +远程执行模块(Agents)是实现分布式、多节点任务管理的关键,允许您在一台主控制面板上协同调度部署在不同区域、不同操作系统的任务。 + +## Agents 架构 + +- **Agent 节点**:独立运行的小型客户端程序,监听主面板的任务分配。 +- **通信协议**:基于高效且稳定的消息队列与 WebSocket 协议,确保持久化双向实时通信。 +- **异构环境**:Agent 完全支持 Linux、Windows 及 macOS。您可以将 Agent 安装在轻量级树莓派、本地闲置电脑甚至异地数据中心。 + +## 节点管理 + +- **节点注册**:通过唯一的指纹认证,确保存储与数据传输的安全性。 +- **多状态监控**: + - `在线`:节点准备就绪,可以接受任务。 + - `离线`:失去连接,所有指派的任务将自动进入等待或失败逻辑(取决于具体任务配置)。 + - `异常`:连接握手失败或版本由于过低暂不支持。 + + +## 指派任务 + +1. 在 `定时任务` 编辑页面,将执行器选项从 `本地执行` 切换为特定的 Agent 节点。 +2. 任务执行完成后,所有的控制台日志将通过加密隧道回传至主面板进行统一存储与检索。 diff --git a/docs/guide/api.md b/docs/guide/api.md new file mode 100644 index 0000000..10ef780 --- /dev/null +++ b/docs/guide/api.md @@ -0,0 +1,51 @@ +--- +layout: false +--- + + + +
+ + + +
+ + diff --git a/docs/guide/changelog.md b/docs/guide/changelog.md new file mode 100644 index 0000000..6a752f3 --- /dev/null +++ b/docs/guide/changelog.md @@ -0,0 +1,80 @@ +# 更新日志 ☕ + +本页面记录了taskpool的主要版本更新历史。 + +## 最近更新概览 + +### 2026.07.13 - 日志 ZSTD 压缩、依赖补全与计划任务排序 (v1.1.20) +- **日志 ZSTD 压缩升级 (New)**:日志流式压缩机制由 zlib 全面升级至高压缩比的 ZSTD,显著降低磁盘开销与传输带宽;前端集成 `fzstd` 无缝支持新格式解码,并实现了对旧版 zlib 日志的向后兼容;针对小于 128 字节的短日志自动绕过压缩,避免无意义的算力浪费。 +- **依赖自动补全与交互终端 (New) (#147)**:全新上线依赖分析与自动补全安装 CLI,并提供终端安装向导提示;在定时任务日志界面右下角,现可通过“补全依赖”按钮一键调出内嵌终端进行交互式依赖安装。 +- **定时任务排序功能 (#148)**:任务列表(大屏与中屏)现已支持点击表头“名称”、“执行时间”(下次执行时间)和“状态”进行排序;同时小屏/移动端顶栏新增了“排序规则”下拉菜单,规则无缝统一。 +- **全局 ESC 关闭弹窗**:在通用组件 `DialogContent` 内部集成非侵入式 Escape 按键全局捕获,按 ESC 优先退出最顶层弹窗,避免输入框/Monaco等组件焦点占用导致退出失效。 +- **视图管理与任务优化**:任务列表自定义视图现已可联动保存当前的排序状态并自动还原;为新建任务的日志清理配置默认设置为保留最近 30 条记录,防止磁盘占满;日志详情弹窗增加了最大高度及滚动条优化。 +- **样式与体验优化**:将“状态”列宽度由 `w-8` 扩大至 `w-14` 消除因加入排序图标导致的文字折行与表头挤压;去除了 Dialog 自动聚焦时产生的窗口边缘白色高亮聚焦线。 + +### 2026.06.28 - 全新节点互联功能发布! (v1.1.17) +- **节点互联体系 (New)**:新增开放连接(OpenConnect)协议支持,轻松打通并管理多个任务池实例;全新上线同步管理面板,可实时总览所有连接节点的资源开销与各项负载指标;支持了跨节点间的环境变量全量无缝同步(完美保留原始结构与关联 ID);底层路由机制迎来全面升级,完美支持基于穿透隧道的前端互联访问代理。 +- **终端体验优化**:针对移动端深度优化终端(xterm)交互,禁用移动端点击自动弹出软键盘,支持选中内容后 Ctrl+C 快捷复制,并大幅提升了小屏幕下的滑动流畅性。 +- **调度器安全**:为 Worker 数量和队列大小添加了边界验证及安全限制,从底层防止高并发场景下出现 OOM 问题。 +- **UI 体验优化**:修复了大屏模式下环境变量表格删除按钮丢失的问题;MasterView 按钮在小屏幕下支持自适应填满;增加了演示模式下互联角色的操作限制。 + + +### 2026.06.22 - 体验优化与 Bug 修复 (v1.1.16) +- **执行历史自适应 (New)**:修复了执行历史列表及日志卡片高度被固定限制在 `520px` 的 Bug,改为通过 `calc(100vh - 190px)` 动态铺满视口,大幅提升大屏及竖屏利用率(#134)。 +- **通知日志截断 Bug 修复**:修复了任务通知中执行日志在全局被提前硬编码截断导致个性化字数限制失效的 Bug(#133)。 +- **去颜色性能优化**:将清除 ANSI 控制字符的正则操作移到循环外部只运行一次,降低了多通道投递时的 CPU 占用(#133)。 +- **机密使用指引**:在机密管理 UI 中增加了醒目的使用指引说明,支持手动关闭并可本地记忆关闭状态防止打扰(#135)。 +- **数据恢复兼容性**:在导入/恢复备份包时,支持自动检测并迁移旧版本 task 中的环境变量及 tags 数据(#135)。 +- **其它优化与修复**:支持自定义仓库同步文件夹名称(#132);支持输出当前版本的 `version` 命令;修复了终端 UTF-8 截断引发的乱码缺陷;CI 新增 arm64 构建支持。 + +### 2026.06.12 - 调度器与面板资源实时监控 +- **资源监控大盘 (New)**:新增对面板底层运行资源、调度器并发池(Worker)以及内存堆栈状态的实时高频监控展示。 + +### 2026.05.29 - 前端定制与 WebUI 插件化 +- **自定义 WebUI 支持 (New)**:新增了前端自定义打包与热切换功能。面板系统彻底解耦前后端静态资源,用户可以在“系统设置 - 前端定制”中上传并管理自定义前端资源包,实现深度的主题替换与定制。 +- **打包工具链整合**:提供了一键构建前端定制包的 `make pack-webui` 快捷命令与规范(自动生成 `uimanifest.json`)。 +- **动态资源托管**:Go 后端引入动态静态资源拦截机制,可无缝接管系统入口与单页应用渲染,同时向下兼容内置面板。 + + +### 2026.04.16 - 内建脚本助手库 (Built-in SDK) +- **内建助手库 (Built-in SDK) (New)**:新增 Python 与 Node.js 的轻量级助手库 `taskpool`。通过环境自动注入机制实现“零配置”通知投递,开发者无需在脚本中显式配置 TOKEN 或 URL。 +- **环境自动初始化**:新增 `taskpool builtininstall` 命令行工具,支持一键为 `mise` 管理的所有多语言版本同步安装/刷新内建包依赖。 +- **体验与文档升级**:重构了「脚本调用」UI 指引,简化了集成步骤说明,并统一了全局 UI 字体规范。 + +### 2026.04.14 - PWA 支持与推送渠道扩展 +- **PWA 动态配置 (New)**:支持 Progressive Web App 动态 manifest 配置,应用名称和图标可由后端站点设置实时动态注入。 +- **VoceChat 推送支持**:新增对 VoceChat 私有化部署推信渠道的支持(基于 Bot API)。 +- **Bark 增强**:Bark 推送渠道新增“自定义服务器”支持,适配 Bark 私有化部署场景与加密推送。 + +### 2026.03.27 - 安全机密管理 (GitHub Secrets 风格) +- **安全机密功能 (New)**:引入类似 GitHub Actions 的机密管理机制。支持使用 **AES-GCM** 加密存储敏感变量,数据库不存明文,保障配置安全。 +- **秘钥内存常驻销毁 (Safe-Unset)**:系统启动从环境变量读取秘钥后会立即执行 `Unset` 操作,确保秘钥仅保留在内存中,不暴露在进程环境内。 +- **日志自动脱敏**:实时流水日志自动扫描并掩码(Mask)脱敏显示机密内容(`********`),防止执行时通过任务输出泄露机密。 +- **严格隔离机制**:机密**仅在定时调度任务**时生效,终端命令执行、手动测试、调试等入口物理隔离机密。 + +### 2026.03.19 - 仓库同步功能增强 +- **青龙指令深度兼容**:支持直接粘贴青龙格式的仓库同步指令,自动解析并创建任务。 + +### 2026.03.05 - API 文档重构 +- **OpenAPI 认证体系**:支持站点级 Token 配置与 Basic Auth 保护。 +- **自定义 UI**:新增设计感十足的全局 **404 页面**。 + +### 2026.03.04 - 消息推送系统重构 +- **原生内置**:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道。 +- **事件捕获**:接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史。 + +### 2026.02.13 - 任务执行引擎重构 +- **深度集成 Mise**:支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换。 +- **依赖管理**:同步上线跨语言统一依赖管理系统。 + +### 2026.02.11 - 安全性增强 +- **随机密码策略**:首次启动使用随机密码并打印在日志中。 +- **暴力破解防护**:登录接口增加防暴力破解。 +- **路径遍历防护**:文件系统操作增加路径穿越锁定。 + +### 2026.02.10 - 任务调度重构 +- **调度性能**:重写了并发控制逻辑,完善了任务队列。 +- **体验优化**:优化文件树交互体验,支持任务执行实时日志流。 + +### 2026.02.06 - 镜像扩展 +- **Debian 13 支持**:增加对 Debian 13 (Trixie) 镜像支持,整理 Docker 目录结构。 diff --git a/docs/guide/cli.md b/docs/guide/cli.md new file mode 100644 index 0000000..9f76a54 --- /dev/null +++ b/docs/guide/cli.md @@ -0,0 +1,166 @@ +# 命令行工具 (CLI) + +taskpool在环境内内置了同名的 `taskpool` 命令行工具。如果您在终端内需要执行系统级别的操作,可以使用这些内置命令。 + +## 常用核心指令 + +| 命令 | 描述 | +| :--- | :--- | +| `taskpool server` | 面板启动指令,运行服务端后台进程。 | +| `taskpool reposync` | 供定时任务调用,将远程 Git 仓库的高级特性同步到本地目录中。 | +| `taskpool resetpwd` | 交互式重置系统 admin 账号密码(密码丢失时可通过进入终端重置)。 | +| `taskpool restore ` | 使用本地的 .zip 备份压缩包文件,一条命令直接全量恢复系统数据。 | +| `taskpool task` | 极速只读与控制台常驻任务管理(支持查询列表、手动触发、查看状态及开关控制)。 | + +--- + +## 使用场景示例 + +### 1. 密码重置 +您可以进入 Docker 容器或通过 ssh 连入宿主机控制台: +```bash +docker exec -it taskpool taskpool resetpwd +``` +然后根据提示,输入新的管理员密码即可重置成功。 + +### 2. 手动启动 +如果是通过手动部署二进制文件,可以使用 `taskpool server` 启动: +```bash +nohup ./taskpool server > /dev/null 2>&1 & +``` + +### 3. 数据恢复 +上传备份后的 ZIP 文件至容器目录: +```bash +docker exec -it taskpool taskpool restore /app/data/backup-2026xxxx.zip +``` +该操作会全量覆盖现有数据库和脚本文件,请谨慎操作。 + +--- + +## `reposync` 参数详解 + +`taskpool reposync` 是面板核心的同步命令,除了在任务中自动调用外,您也可以通过命令行手动执行。 + +### 参数列表 + +| 参数名 | 默认值 | 描述 | +| :--- | :--- | :--- | +| `--source-type` | `git` | 同步源类型,可选 `git`(Git 仓库)或 `url`(文件直链下载)。 | +| `--source-url` | | 同步源地址,Git 仓库地址或下载 URL。 | +| `--target-path` | | 目标保存路径。支持变量替换(如 `$SCRIPTS_DIR$`)。 | +| `--branch` | | Git 分支名。留空时将自动检测远程默认分支(如 `main` 或 `master`)。 | +| `--path` | | 稀疏检出(Sparse checkout)的指定路径,或在单文件模式下的相对路径。 | +| `--single-file` | `false` | 是否开启单文件模式,仅从 Git 提取指定单个文件。 | +| `--proxy` | `none` | Github 加速代理类型,可选 `none`、`ghproxy`、`mirror`、`custom`。 | +| `--proxy-url` | | 自定义代理地址,仅在 `--proxy=custom` 时生效。 | +| `--auth-token` | | 私有仓库或 API 访问使用的鉴权 Token。 | +| `--http-proxy` | | HTTP/HTTPS 代理地址,例如 `http://127.0.0.1:7890`。 | +| `--whitelist-paths`| | 白名单路径(逗号或竖线分隔),同步时受保护不被清理的路径。 | +| `--blacklist` | | 黑名单关键字(竖线 `\|` 分隔),包含该关键字的文件将会被过滤删除。 | +| `--dependence` | | 依赖文件关键字(竖线 `\|` 分隔),这些文件将强制保留。 | +| `--extensions` | | 允许的脚本扩展名(竖线 `\|` 分隔,如 `.js\|.py`),后缀不符的文件将被删除。 | +| `--task-id` | | 内部任务 ID,用于在同步完成后通知调度器刷新增量任务。 | +| `--task-langs` | | 任务配置的语言(JSON格式),用于标记和解析。 | +| `--repo-task-id` | | 原始任务 ID。 | +| `--task-timeout` | `30` | 同步任务的超时时间,单位为分钟。 | +| `--commenttotask` | `false` | 是否启用青龙 (QL) 格式的脚本注释解析(`true`/`false`)。 | + +### 使用示例 + +#### 1. 基础 Git 仓库同步 +将指定仓库克隆或拉取到特定目录: +```bash +taskpool reposync --source-url https://github.com/example/repo.git --target-path /app/data/scripts/example_repo +``` + +#### 2. 启用代理的同步 +针对 Github 仓库使用加速代理,并限定只保留 `.js` 和 `.py` 脚本: +```bash +taskpool reposync --source-url https://github.com/example/repo.git \ + --target-path /app/data/scripts/example_repo \ + --proxy ghproxy \ + --extensions ".js|.py" +``` + +#### 3. 稀疏检出 (Sparse Checkout) +当仓库庞大时,仅同步特定的子目录或文件: +```bash +taskpool reposync --source-url https://github.com/example/repo.git \ + --target-path /app/data/scripts/example_repo \ + --path "scripts/daily" +``` + +#### 4. 单文件下载模式 +如果只需要仓库中的某一个脚本文件: +```bash +taskpool reposync --source-url https://github.com/example/repo.git \ + --target-path /app/data/scripts/ \ + --single-file true \ + --path "main_script.py" +``` + +#### 5. 高级过滤与青龙注释解析 +使用黑名单排除特定脚本,并开启青龙格式注释解析以自动生成定时任务: +```bash +taskpool reposync --source-url https://github.com/example/repo.git \ + --target-path /app/data/scripts/example_repo \ + --blacklist "test|mock" \ + --dependence "package.json|requirements.txt" \ + --commenttotask "true" +``` + +--- + +## `taskpool task` 任务管理指令集 + +`taskpool task` 是一组专为纯终端操作与自动化脚本调度打造的轻量级任务管理子命令集。它能够绕过繁重的界面操作,直接提供闪电般的本地查询与安全指令下发控制。 + +### 支持子命令 + +#### 1. 任务列表查询 (`list`) +查询并分页展示系统内配置的所有任务概览。 +```bash +# 默认展示前 20 条 +taskpool task list + +# 指定关键词过滤,并查看第 2 页 (每页展示 10 条) +taskpool task list -q "签到" -page 2 -size 10 +``` + +#### 2. 手动立即触发 (`run`) +手动向常驻后台服务下发指令,立即异步运行指定的任务。 +```bash +taskpool task run a1b2c3d4 +``` + +#### 3. 任务状态切换 (`enable` / `disable`) +快速启用或禁用系统任务。 +```bash +taskpool task enable a1b2c3d4 +taskpool task disable a1b2c3d4 +``` + +#### 4. 实时执行状态追踪 (`status`) +查看指定任务最新一次执行的详细输出日志和最终退出码。 +```bash +# 查看最近一条日志 +taskpool task status a1b2c3d4 + +# 查看指定历史日志条目的完整输出 +taskpool task status a1b2c3d4 log_123456 +``` + +#### 5. 近期执行历史流水 (`history`) +列出某任务最近的多次运行记录(包含耗时、执行时间及状态结果)。 +```bash +taskpool task history a1b2c3d4 +``` + +> [!TIP] +> 所有的 `taskpool task` 子命令均原生支持单独传入 `--help` 参数获取具体的示例和选项清单。例如:`taskpool task list --help`。 + +--- + +## 其他帮助 +终端内直接执行 `taskpool` 即可在控制台直接打印内置支持详细说明和命令列表参数。 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 0000000..e4c6c38 --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,90 @@ +# 系统配置手册 + +taskpool支持通过环境变量和配置文件两种核心方式进行系统参数微调。 + +## 环境变量配置 (优先级最高) + +环境变量在容器内自动注入,非常适合 CI/CD 和 Docker 混合编排场景。 + +### 核心配置项列表 + +| 环境变量 | 对应配置 | 说明 | 默认值 | +| :--- | :--- | :--- | :--- | +| `BH_SERVER_PORT` | server.port | 服务监听端口 | 8052 | +| `BH_SERVER_HOST` | server.host | 监听地址 | 0.0.0.0 | +| `BH_SERVER_URL_PREFIX` | server.url_prefix | URL 前缀,用于反向代理子路径部署 | - | +| `BH_COOKIE_NAME` | server.cookie_name | 全局会话 Cookie 名称 | BHToken | +| `BH_DB_TYPE` | database.type | 数据库类型 (sqlite/mysql) | sqlite | +| `BH_DB_HOST` | database.host | 数据库实例地址 | localhost | +| `BH_DB_PORT` | database.port | 数据库端口 | 3306 | +| `BH_DB_USER` | database.user | 数据库用户名 | root | +| `BH_DB_PASSWORD` | database.password | 数据库密码 | - | +| `BH_DB_NAME` | database.dbname | 数据库库名 | taskpool | +| `BH_DB_PATH` | database.path | SQLite 物理文件存储路径 | ./data/taskpool.db | +| `BH_DB_DSN` | database.dsn | 数据库 DSN (仅 mysql/postgres, 优先级高。**需对应设置 type**) | - | +| `BH_DB_TABLE_PREFIX` | database.table_prefix | 数据库表前缀 | taskpool_ | +| `BH_DB_SSL_MODE` | database.ssl_mode | SSL 模式: postgres 支持 disable/require/verify-ca/verify-full; mysql 支持 true/skip-verify | - | +| `TASKPOOL_SECRET_KEY` | - | 系统加密秘钥,用于机密变量功能(**注:仅支持环境变量设置,不支持配置文件**) | - | + +--- + +## 配置文件挂载 (config.ini) + +如果您希望对系统参数有更细致的控制(而非通过外部注入),可以使用配置文件。 + +### 挂载点 +```yaml +volumes: + - ./configs:/app/configs +``` + +### 配置文件示例 (`configs/config.ini`) +```ini +[server] +port = 8052 +host = 0.0.0.0 +# 配置 URL 前缀用于反向代理,例如 /taskpool/ +url_prefix = /taskpool +# 全局会话 Cookie 名称 +cookie_name = BHToken + +[database] +type = sqlite +path = /app/data/taskpool.db +# 数据库连接示例 (Unix Socket / DSN): +# 注意:使用 dsn 时,type 必须设为 mysql 或 postgres +# dsn = user:password@unix(/var/run/mysqld/mysqld.sock)/dbname?charset=utf8mb4&parseTime=True&loc=Local +# dsn = postgres://user:password@localhost:5432/dbname?sslmode=disable +table_prefix = taskpool_ +``` + +--- + +## 调度设置说明 + +系统采用异步任务队列 + Worker Pool 架构,可在「系统设置 > 调度设置」页面进行配置: + +- **Worker 数量** (默认 4):同时在后端并发运行的任务进程数。 +- **队列大小** (默认 100):待处理任务队列的最大容量。 +- **速率间隔** (默认 200 ms):控制两个任务启动之间的最小等待时长。 + +--- + +## 机密管理 (Secret Management) + +taskpool提供了一套基于 **AES-GCM** 工业级标准的安全机密管理系统,其设计理念参考了 GitHub Actions Secrets。 + +### 核心特性 + +1. **强加密存储**:所有标记为“机密”的变量在数据库中均以加密密文形式存储。 +2. **秘钥安全**:通过环境变量 `TASKPOOL_SECRET_KEY` 注入加密秘钥。系统读取秘钥后会立即将其从进程环境变量中销毁(Unset),确保秘钥仅驻留在内存中。 +3. **日志自动脱敏**:系统会自动扫描任务执行生成的实时日志流。一旦发现机密明文,将自动替换为 `********`,防止敏感信息通过日志泄露。 +4. **严格权限隔离**: + - 机密内容**仅在计划任务由调度器定时执行时**才会注入到环境。 + - 通过**终端命令**、**测试运行**或**调试运行**调起的临时进程无法获取机密内容,保障核心资产安全。 + +### 配置建议 + +- 建议在 Docker/Compose 启动项中设置 `TASKPOOL_SECRET_KEY` 为一个复杂的随机字符串。 +- 不要将该秘钥写入 `config.ini` 或提交到版本控制系统。 + diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md new file mode 100644 index 0000000..1304296 --- /dev/null +++ b/docs/guide/dashboard.md @@ -0,0 +1,16 @@ +# 数据仪表 + +数据仪表提供了taskpool的全局运行状态、任务执行统计及关键指标的可视化展示。 + +## 主要功能 + +- **总体概览**:实时统计当前面板的总任务数、正在运行的任务数、总脚本数以及 Agent 节点的状态。 +- **执行状况统计**:通过饼图展示过去 24 小时或 7 天内任务执行的成功、失败及因超时被自动强制终止的比例。 +- **并发趋势监测**:折线图实时呈现任务并发执行的高峰与低谷,辅助管理员评估物理硬件或云服务器的负载情况。 +- **资源监控**:实时获取主机 CPU、内存在线占用状态,确保面板在资源充裕的环境下高效运转。 + +## 面板布局 + +1. **状态卡片**:位于顶部,快速掌握系统规模。 +2. **执行热力图**:展示不同时段的任务运行频次。 +3. **系统性能仪表**:直观展示核心硬件利用率。 diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md new file mode 100644 index 0000000..19b3f22 --- /dev/null +++ b/docs/guide/deployment.md @@ -0,0 +1,181 @@ +# 快速部署 + +项目提供多种基础镜像,默认版本基于 Debian 12,集成了 Python 3.13 与 Node.js 23。 + +## 基础镜像选择 + +| 标签 (Tag) | 基础镜像 | 说明 | +| :--- | :--- | :--- | +| `latest` | Debian 12 | **默认推荐**:集成 Python 3.13 与 Node.js 23,开箱即用 | +| `latest-debian13` | Debian 13 | 尝鲜版本,基于 Debian Trixie | +| `latest-minimal` | Debian 13 | **最小化版**:不预置任何语言环境,仅内置 Mise,适合追求极致纯净的用户 | + +> **提示**:目前默认使用 `latest` 标签。如需切换环境,只需将镜像名后的 `latest` 替换为 `latest-minimal`(极致纯净)或 `latest-debian13` 即可。 + +## 环境版本重构说明 (2026.02.13+) + +> **警告**:架构升级破坏性变更 +> +> 本版本(2026.02.13+)对底层运行时环境进行了彻底重构,弃用了原有的静态 Python/Node 环境,转为使用 **Mise** 进行动态版本管理。 +> +> 1. **不再提供 Alpine 镜像**:由于 glibc 兼容性问题,Mise 无法在 Alpine 上完美运行,因此暂时取消 Alpine 镜像支持。 +> 2. **环境数据不兼容**:如果您是从旧版本升级上来,原有的 Python/Node 环境数据将无法迁移。您需要清空挂载的 `envs/` 目录并让其由新容器自动初始化。 + +--- + +## 方式一:Docker 运行 (环境变量配置) + +通过环境变量指定配置,简单灵活,适合一般部署。 + +### SQLite (默认) +```bash +docker run -d \ + --name taskpool \ + -p 8052:8052 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/envs:/app/envs \ + -e TZ=Asia/Shanghai \ + -e BH_SERVER_PORT=8052 \ + -e BH_SERVER_HOST=0.0.0.0 \ + -e BH_DB_TYPE=sqlite \ + -e BH_DB_PATH=/app/data/taskpool.db \ + -e BH_DB_TABLE_PREFIX=taskpool_ \ + --restart unless-stopped \ + ghcr.io/engigu/taskpool:latest +``` + +### MySQL +```bash +docker run -d \ + --name taskpool \ + -p 8052:8052 \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/envs:/app/envs \ + -e TZ=Asia/Shanghai \ + -e BH_SERVER_PORT=8052 \ + -e BH_SERVER_HOST=0.0.0.0 \ + -e BH_DB_TYPE=mysql \ + -e BH_DB_HOST=mysql-server \ + -e BH_DB_PORT=3306 \ + -e BH_DB_USER=root \ + -e BH_DB_PASSWORD=your_password \ + -e BH_DB_NAME=taskpool \ + -e BH_DB_TABLE_PREFIX=taskpool_ \ + --restart unless-stopped \ + ghcr.io/engigu/taskpool:latest +``` + +--- + +## 方式二:Docker Compose 部署 + +推荐的生产环境部署方式。 + +### 核心部署模板 +```yaml +services: + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=sqlite + - BH_DB_PATH=/app/data/taskpool.db + - BH_DB_TABLE_PREFIX=taskpool_ + # - BH_SERVER_URL_PREFIX=/taskpool # 可选:配置 URL 前缀用于反向代理 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped +``` + +--- + +## 方式三:配置文件挂载模式 + +通过挂载 `/app/configs/config.ini` 来管理详细配置。 + +### 配置文件挂载示例 +```yaml +volumes: + - ./data:/app/data + - ./configs:/app/configs + - ./envs:/app/envs +``` +--- + +## Docker 启动流程 + +容器启动时 `docker-entrypoint.sh` 会自动执行以下关键步骤: + +1. **环境自检**:检查 `/app/data`、`/app/configs`、`/app/envs` 挂载点并创建必要子目录。 +2. **Mise 同步**:自动将镜像内置的 Mise 核心运行时激活文件同步到持久化挂载目录中,确保容器重启后环境依然可用。 +3. **运行时激活**:动态注入环境变量,将 `mise shims` 路径加入系统 `PATH`。 +4. **包管理预设**:自动为 Python 配置清华源 (PIP) 镜像,配置 Node.js 内存限制。 +5. **主进程启动**:运行 `taskpool server` 开启面板。 + +> **提示**:通过持久化挂载 `./envs` 目录,您安装的所有运行时版本和第三方依赖库均会永久保留。 + +--- + +## 自动更新 Docker 镜像 + +如果您希望taskpool能够自动拉取最新镜像并无感更新,推荐使用 **Watchtower**。Watchtower 会定期检查被监控容器的基础镜像,当发现有新版本推送时,它会自动拉取新镜像、使用与原容器完全相同的配置重启容器。 + +由于taskpool采用持久化挂载(数据和环境都在外部),因此自动更新不会造成任何数据丢失。 + +### 方式一:独立一行命令运行 Watchtower(推荐) + +执行以下命令,Watchtower 将会自动在每天凌晨 3 点自动检查并更新名为 `taskpool` 的容器: + +```bash +docker run -d \ + --name watchtower \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e TZ=Asia/Shanghai \ + -e WATCHTOWER_SCHEDULE="0 0 3 * * *" \ + -e WATCHTOWER_CLEANUP=true \ + --restart unless-stopped \ + containrrr/watchtower \ + taskpool +``` + +> **参数说明**: +> - 结尾处的 `taskpool` 为指定仅监控更新名为 `taskpool` 的容器。若不加此参数,则会自动更新宿主机上所有的 Docker 容器。 +> - `WATCHTOWER_CLEANUP=true`:更新成功后自动删除旧版本的废弃镜像,防止存储空间被占满。 +> - `WATCHTOWER_SCHEDULE`:设置定时检查的 Cron 表达式(秒 分 时 日 月 周)。 + +### 方式二:集成到 Docker Compose 中 + +您可以直接将 Watchtower 作为附加服务加入现有的 `docker-compose.yml` 中: + +```yaml +services: + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + # ... 省略端口、挂载等其他配置 ... + + watchtower: + image: containrrr/watchtower + container_name: watchtower + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - TZ=Asia/Shanghai + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_SCHEDULE="0 0 3 * * *" + command: taskpool + restart: unless-stopped +``` + +修改完成后,执行 `docker compose up -d` 生效即可。 diff --git a/docs/guide/disclaimer.md b/docs/guide/disclaimer.md new file mode 100644 index 0000000..1fca3c0 --- /dev/null +++ b/docs/guide/disclaimer.md @@ -0,0 +1,24 @@ +# 免责声明 + +taskpool(TaskPool)及其开发者在提供本项目的同时,默认用户已完全知悉并同意以下条款: + +## 1. 免责保证 + +- **无业务逻辑**:本项目仅作为一个轻量级的任务托管与调度平台,不提供、不内置任何具有实际业务逻辑的第三方脚本。 +- **脚本审核**:用户自行添加或配置的脚本来源、逻辑及潜在的系统影响均由用户自行负责。请勿执行来源不明的恶意脚本,并在执行前仔细阅读并审核其源代码,确保安全性。 + +## 2. 软件责任 + +- **按“原样”提供**:本项目属于业余开源开发作品,采用 Apache License 2.0 协议发布(需遵守 NOTICE 署名要求)。开发者不保证软件不存在任何 Bug、系统漏洞或逻辑缺陷。 +- **损失赔偿**:因运行用户自行脚本或使用本系统带来的一切数据泄露、系统损坏、财产损失(如服务器被封、云服务欠费)及相关法律责任,均由使用者本人承担。 + +## 3. 授权与使用 + +- **合理镜像申请**:由于项目涉及到网络请求和资产调度,请遵循相关的开源协议进行公平、合理的使用。 +- **禁止非法用途**:严禁将taskpool用于任何违反中华人民共和国法律法规及相关组织政策的行为。 + +--- + +## 4. 联系我们 + +如果您在使用过程中发现任何技术问题,欢迎通过 GitHub [Issues](https://github.com/engigu/taskpool/issues) 反馈。 diff --git a/docs/guide/environments.md b/docs/guide/environments.md new file mode 100644 index 0000000..b49f678 --- /dev/null +++ b/docs/guide/environments.md @@ -0,0 +1,19 @@ +# 变量机密 + +变量机密提供了统一的环境变量管理功能,旨在保护敏感数据并提高配置的灵活性,包含环境变量和机密。 + +## 环境变量 (Environment Variables) + +- **全局作用域**:一旦在变量机密页面配置成功,所有的 `定时任务` 与 `命令行交互` 在运行期间均会自动注入这些环境变量。 +- **变量命名规范**:建议使用大写字母加下划线的形式,例如 `DB_PASSWORD` 或 `AUTH_TOKEN`。 + + +## 机密 (Secrets) + +- **字段脱敏**:对于标记为 `Secret` 的变量,面板在浏览列表中将以星号 `*******` 显示,避免在协作或投屏场景下泄露机密信息。 +- **加密存储**:数据库中的敏感字段均由系统后端进行深度加密,确保存储层的物理安全。 +- **编辑权限**:某些机密字段可能在编辑后不可见其原始值,仅支持通过覆盖更新的方式进行修改。 + +## 注入机制 + +- **任务运行时动态挂载**:在启动任务对应的进程前,主进程或 Agent 会将配置好的键值对同步至子进程的 `Environment` 参数中,确保脚本可以直接通过 `os.environ` 或 `process.env` 获取到该配置。 diff --git a/docs/guide/examples/browser.md b/docs/guide/examples/browser.md new file mode 100644 index 0000000..fcf82d3 --- /dev/null +++ b/docs/guide/examples/browser.md @@ -0,0 +1,191 @@ +# 浏览器示例 + +`example/playwright` 提供了一组远程浏览器脚本示例,演示如何连接 **Browserless**。 + +> [!NOTE] +> 本示例以 Browserless 为主要演示对象。以此类推,您也可以使用其他的浏览器镜像(如原生的 `headless-shell` 或其他的浏览器集群服务)进行部署,只要它们支持 CDP 协议。 + +--- + +## 部署方式对比 + +taskpool强烈建议采用 **单独部署浏览器服务(如 Browserless)** 的方案,而不是在任务池镜像内部安装浏览器。 + +### 本地部署 (在任务池容器内安装) 的缺点 +- **资源争抢**:浏览器是极度的“内存/CPU 杀手”,在同一容器内运行多任务极易导致任务池主进程因 OOM (内存溢出) 而崩溃。 +- **镜像臃肿**:安装 Chromium 后,原本精简的 Docker 镜像体积会暴增数倍(增加 500MB+),导致拉取和更新缓慢。 +- **依赖环境复杂**:在精简镜像中安装浏览器常会遇到各种缺失 `.so` 库文件的底层错误,排查极其困难。 +- **不利于扩展**:无法实现多节点负载均衡,一个容器内的资源始终是有限的。 + +### 远程部署 (Browserless) 的优势 +- **性能隔离**:浏览器的负载波动不会影响taskpool的稳定性。 +- **开箱即用**:专业的浏览器镜像是针对性优化的,包含所有底层依赖和沙箱安全配置。 +- **可视化调试**:大多数服务(如 Browserless)支持通过 VNC 同步查看浏览器画面,方便排查脚本逻辑。 +- **弹性伸缩**:支持多会话、多实例模式,可以应对高并发爬虫需求。 + +--- + +## 准备工作 + +在taskpool中运行浏览器自动化脚本前,需要先配置好对应的 **语言环境** 与 **第三方依赖包**。 + +### 1. Node.js 环境 (JavaScript) +如果您使用 `playwright.js` 脚本: +- **依赖安装**:前往「语言依赖」->「Node.js」,安装 `puppeteer-core`。 +- **说明**:该脚本使用 `puppeteer-core` 通过 CDP 协议连接远程浏览器,无需安装完整的 puppeteer 及其内置浏览器。 + +### 2. Python 环境 +如果您使用 `playwright.py` 脚本: +- **版本推荐**:建议在「语言环境」中安装并使用 **Python 3.11**。 + > [!TIP] + > 建议避开更高版本的 Python(如 3.12+),因为目前部分 Playwright 依赖在极新版本的 Python 环境下可能会遭遇编译或安装失败。 +- **依赖安装**:前往「语言依赖」->「Python」,安装 `playwright`。 + +--- + +配置完成后,即可创建定时任务并关联对应的脚本文件。 + +## Browserless 连接要点 + +如果你当前是通过 Browserless 连接远程浏览器: + +- 不要执行 `playwright install` +- 不需要额外下载 Chromium / Firefox / WebKit +- 直接使用 `connect_over_cdp` 连接远程浏览器即可 + +## 适用场景 + +- 验证任务池到 Browserless 的网络连通性 +- 验证远程浏览器地址和 Token 是否配置正确 +- 快速测试浏览器自动化脚本是否能正常运行 +- 快速确认 Node.js / Python 语言环境是否已经配置完成 +- 作为后续网页自动化脚本的基础模板 + +## 推荐部署方式 + +建议配合 `browserless/chromium` 一起使用,再由任务池中的脚本连接远程浏览器服务。 + +参考 `docker-compose.yml`: + +```yaml +version: "3.8" + +services: + browser: + image: ghcr.io/browserless/chromium:latest + container_name: browser + restart: unless-stopped + environment: + MAX_CONCURRENT_SESSIONS: 5 + MAX_QUEUE_LENGTH: 20 + CONNECTION_TIMEOUT: 300000 + DEFAULT_LAUNCH_ARGS: '["--no-sandbox","--disable-setuid-sandbox","--disable-dev-shm-usage"]' + TOKEN: your-secret-token + ENABLE_DEBUGGER: "false" + shm_size: "1gb" + mem_limit: 2g + cpus: 2 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"] + interval: 30s + timeout: 10s + retries: 3 + + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + restart: unless-stopped + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=sqlite + - BH_DB_PATH=/app/data/taskpool.db + - BH_DB_TABLE_PREFIX=taskpool_ + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + depends_on: + - browser +``` + +## 使用步骤 + +1. 启动 `browser` 和 `taskpool` 服务。 +2. 在任务池的“语言依赖”中安装对应包。 +3. 按实际环境修改脚本中的 Browserless 地址和 Token。 +4. 在任务池中创建任务并运行对应脚本。 + +Python 示例的核心写法如下: + +```python +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.connect_over_cdp( + "http://browser:3000?token=your-secret-token" + ) + page = browser.new_page() + page.goto("https://www.baidu.com") + page.screenshot(path="baidu.png") + browser.close() +``` + +## 运行前检查 + +- Browserless 服务已经正常启动 +- 任务池可以访问 Browserless 地址 +- `TOKEN` 与 Browserless 配置保持一致 +- 脚本中的地址、端口和协议填写正确 + +## 常见问题 + +### 1. 报错提示找不到模块 + +通常是还没有在“语言依赖”中安装对应包: + +- Node.js 示例需要 `puppeteer-core` +- Python 示例需要 `playwright` + +### 2. 为什么没有执行 `playwright install` + +这是预期行为。 + +如果你使用的是 Browserless 这类远程浏览器服务,Playwright 只是作为客户端发起连接,不需要在任务池容器里再下载本地浏览器,所以通常不要执行 `playwright install`。 + +### 3. 连接不上 Browserless + +请优先检查: + +- Browserless 服务是否正常启动 +- Token 是否正确 +- 任务池与 Browserless 是否在同一网络中 +- 地址是否写成了当前运行环境可访问的地址 + +### 4. 页面打开超时 + +可以尝试: + +- 换一个更稳定的目标站点 +- 调大超时时间 +- 增加 Browserless 容器的 `shm_size` +- 检查容器 CPU / 内存是否不足 + +### 5. 没有看到截图文件 + +请确认: + +- 脚本已经执行成功 +- 截图保存路径是否正确 +- 任务工作目录是否符合预期 + +## 说明 + +这组示例主要用于快速验证任务池与远程浏览器服务之间的连通性,以及对应语言环境是否已经配置完成。 diff --git a/docs/guide/examples/builtin.md b/docs/guide/examples/builtin.md new file mode 100644 index 0000000..319fee1 --- /dev/null +++ b/docs/guide/examples/builtin.md @@ -0,0 +1,296 @@ +# 内置库示例 + +taskpool提供了一个名为 `taskpool` 的内建包(Built-in SDK),支持 Python 和 Node.js。通过该内置库,您可以在脚本中实现**消息推送**、**环境变量管理**以及**任务执行控制**等高级功能。 + +--- + +## 准备工作 + +在运行内置库脚本之前,请确保完成了以下步骤: + +### 1. 安装内置包 +在taskpool的「终端」页面中,或者通过创建临时任务执行以下命令,为面板管理的所有语言环境安装 `taskpool` 包: + +```bash +taskpool builtininstall +``` + +### 2. 配置环境变量 +根据您需要调用的功能,在定时任务的“环境变量”或“机密”中配置以下对应 Key: + +#### 消息推送所需环境变量 +- **`BHPKG_NOTIFY_TOKEN`**:进入「消息推送」->「脚本调用说明」页面即可找到。 +- **`BHPKG_NOTIFY_CHANNEL`**:进入「消息推送」->「渠道列表」页面,查看对应渠道的 **ID**。 +- **`BHPKG_NOTIFY_URL`** (可选):默认为 `http://localhost:8052/api/v1/notify/send`。如果修改了主服务端口,需要同步修改。 + +#### 环境变量管理与定时任务控制所需环境变量 +- **`BHPKG_OPENAPI_TOKEN`** (或 `OPENAPI_TOKEN`):用于 OpenAPI 接口鉴权,进入「系统设置」->「OpenAPI」页面,生成并复制 Token。 +- **`BHPKG_OPENAPI_URL`** (或 `OPENAPI_URL`,可选):默认为本地面板 API 地址。若在非标准环境下运行,可手动指定(例如 `http://localhost:8052`)。 + +--- + +## 消息通知示例 + +只需要一行代码即可触发零配置推送。 + +::: code-group + +```python [Python] +import taskpool + +def main(): + print("正在尝试发送 Python 内建通知...") + try: + # 调用内置 notify 函数 + # 内部会自动使用环境变量进行鉴权和投递 + response = taskpool.notify( + title="Python 任务提醒", + text="这是一条来自 Python 示例脚本的通知消息。调用非常简单!" + ) + print("发送请求已处理。") + if response: + print(f"服务器响应: {response}") + + except Exception as e: + print(f"发送过程发生异常: {e}") + +if __name__ == "__main__": + main() +``` + +```javascript [Node.js] +const taskpool = require('taskpool'); + +console.log("正在尝试发送 Node.js 内建通知..."); + +try { + // 简单的一行代码即可完成推送,内置包采用异步非阻塞发送 + taskpool.notify( + "Node.js 任务提醒", + "这是一条来自 Node.js 示例脚本的通知消息。无需配置 API 地址或 Token。" + ); + console.log("发送请求已提交。"); + +} catch (e) { + console.error(`通知失败: ${e.message}`); +} +``` + +::: + +--- + +## 环境变量管理 + +内置库支持对面板的环境变量进行增删改查。 + +### 支持方法 + +* **Python**: + - `get_envs()`: 获取所有环境变量列表。 + - `get_env(name)`: 根据变量名称获取详情。 + - `add_env(name, value, remark)`: 添加新的环境变量。 + - `update_env(id, name, value, remark)`: 更新指定 ID 的环境变量值。 + - `delete_env(id)`: 根据 ID 删除环境变量。 +* **Node.js**: + - `getEnvs()`: 获取所有环境变量列表。 + - `getEnv(name)`: 根据变量名称获取详情。 + - `addEnv(name, value, remark)`: 添加新的环境变量。 + - `updateEnv(id, name, value, remark)`: 更新指定 ID 的环境变量值。 + - `deleteEnv(id)`: 根据 ID 删除环境变量。 + +### 代码示例 + +::: code-group + +```python [Python] +import taskpool + +def main(): + print("====== 开始运行 Python 环境变量管理示例 ======") + try: + # 1. 获取全部环境变量 + envs = taskpool.get_envs() + print(f"当前共有 {len(envs)} 个环境变量") + + # 2. 新增一个临时环境变量 + new_env_name = "BHPKG_TEST_KEY" + new_env_val = "HelloTaskPool" + print(f"正在创建环境变量: {new_env_name}...") + created_env = taskpool.add_env( + name=new_env_name, + value=new_env_val, + remark="Python SDK 测试自动创建" + ) + print(f"创建成功: ID={created_env.get('id')}, Name={created_env.get('name')}") + + # 3. 查询刚才创建的环境变量详情 + checked_env = taskpool.get_env(new_env_name) + if checked_env: + print(f"成功查询到变量: {checked_env.get('name')} = {checked_env.get('value')}") + + # 4. 修改该环境变量的值 + updated_val = "HelloTaskPool_Updated" + print(f"正在修改环境变量的值为: {updated_val}...") + updated_env = taskpool.update_env( + id=checked_env.get("id"), + name=new_env_name, + value=updated_val, + remark="Python SDK 测试自动更新" + ) + print(f"更新成功: Value={updated_env.get('value')}") + + # 5. 删除该临时环境变量 + print(f"正在删除临时环境变量: ID={checked_env.get('id')}...") + taskpool.delete_env(checked_env.get("id")) + print("删除成功!") + + except Exception as e: + print(f"环境变量操作失败: {e}") + print("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。") + +if __name__ == "__main__": + main() +``` + +```javascript [Node.js] +const taskpool = require('taskpool'); + +async function main() { + console.log("====== 开始运行 Node.js 环境变量管理示例 ======"); + try { + // 1. 获取全部环境变量 + const envs = await taskpool.getEnvs(); + console.log(`当前共有 ${envs.length} 个环境变量`); + + // 2. 新增一个临时环境变量 + const newEnvName = "BHPKG_TEST_KEY_JS"; + const newEnvVal = "HelloTaskPoolJS"; + console.log(`正在创建环境变量: ${newEnvName}...`); + const createdEnv = await taskpool.addEnv( + newEnvName, + newEnvVal, + "Node.js SDK 测试自动创建" + ); + console.log(`创建成功: ID={createdEnv.id}, Name={createdEnv.name}`); + + // 3. 查询该环境变量 + const checkedEnv = await taskpool.getEnv(newEnvName); + if (checkedEnv) { + console.log(`成功查询到变量: ${checkedEnv.name} = ${checkedEnv.value}`); + + // 4. 修改该环境变量的值 + const updatedVal = "HelloTaskPoolJS_Updated"; + console.log(`正在修改环境变量的值为: ${updatedVal}...`); + const updatedEnv = await taskpool.updateEnv( + checkedEnv.id, + newEnvName, + updatedVal, + "Node.js SDK 测试自动更新" + ); + console.log(`更新成功: Value=${updatedEnv.value}`); + + // 5. 删除该临时环境变量 + console.log(`正在删除临时环境变量: ID={checkedEnv.id}...`); + await taskpool.deleteEnv(checkedEnv.id); + console.log("删除成功!"); + } + + } catch (e) { + console.error(`环境变量操作失败: ${e.message}`); + console.log("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。"); + } +} + +main(); +``` + +::: + +--- + +## 定时任务管理与控制 + +内置库支持查询面板的任务列表、最近的执行结果以及手动触发特定任务的运行。 + +### 支持方法 + +* **Python**: + - `get_tasks()`: 获取所有定时任务列表。 + - `execute_task(id)`: 立即触发指定 ID 任务的运行。 + - `get_last_results()`: 获取最近任务的执行记录。 +* **Node.js**: + - `getTasks()`: 获取所有定时任务列表。 + - `executeTask(id)`: 立即触发指定 ID 任务的运行。 + - `getLastResults()`: 获取最近任务的执行记录。 + +### 代码示例 + +::: code-group + +```python [Python] +import taskpool + +def main(): + print("====== 开始运行 Python 任务管理与执行控制示例 ======") + try: + # 1. 获取所有任务列表 + tasks = taskpool.get_tasks() + print(f"成功获取到 {len(tasks)} 个定时任务:") + for task in tasks[:5]: # 仅打印前5个 + print(f" - [{task.get('id')}] {task.get('name')} (表达式: {task.get('schedule')}, 备注: {task.get('remark')})") + + # 2. 尝试触发第一个任务的运行 + if tasks: + target_task = tasks[0] + print(f"\n尝试手动触发任务运行: [{target_task.get('id')}] {target_task.get('name')}...") + taskpool.execute_task(target_task.get("id")) + print("执行指令发送成功。") + + # 3. 获取最近的执行结果列表 + results = taskpool.get_last_results() + print(f"\n最近共有 {len(results)} 条任务执行记录。") + + except Exception as e: + print(f"任务操作失败: {e}") + print("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。") + +if __name__ == "__main__": + main() +``` + +```javascript [Node.js] +const taskpool = require('taskpool'); + +async function main() { + console.log("====== 开始运行 Node.js 任务管理与执行控制示例 ======"); + try { + // 1. 获取所有任务列表 + const tasks = await taskpool.getTasks(); + console.log(`成功获取到 ${tasks.length} 个定时任务:`); + tasks.slice(0, 5).forEach(task => { // 仅展示前5项 + console.log(` - [${task.id}] ${task.name} (表达式: ${task.schedule || ''}, 备注: ${task.remark || ''})`); + }); + + // 2. 尝试触发第一个任务的运行 + if (tasks.length > 0) { + const targetTask = tasks[0]; + console.log(`\n尝试手动触发任务运行: [${targetTask.id}] ${targetTask.name}...`); + await taskpool.executeTask(targetTask.id); + console.log("执行指令发送成功。"); + } + + // 3. 获取最近的执行结果列表 + const results = await taskpool.getLastResults(); + console.log(`\n最近共有 ${results.length} 条任务执行记录。`); + + } catch (e) { + console.error(`任务操作失败: ${e.message}`); + console.log("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。"); + } +} + +main(); +``` + +::: diff --git a/docs/guide/examples/index.md b/docs/guide/examples/index.md new file mode 100644 index 0000000..2bfc8bd --- /dev/null +++ b/docs/guide/examples/index.md @@ -0,0 +1,27 @@ +# 脚本示例 + +任务池内置了一批可直接参考的脚本示例,适合用来验证运行环境、演示常见用法,或者作为你自己脚本的起点。 + +如果你使用 Docker 镜像启动任务池,容器启动后会自动将仓库中的 `example` 目录同步到脚本目录下。通常你可以在脚本目录中看到: + +```text +example/ +``` + +使用脚本示例前,建议先完成下面几步: + +1. 确认示例文件已经同步到脚本目录。 +2. 根据脚本语言,到“语言依赖”页面安装对应依赖包。 +3. 按实际环境修改脚本中的地址、Token、账号或其他配置。 +4. 在任务管理中选择对应脚本并运行。 + +> [!TIP] +> 如果示例脚本依赖第三方包,但你还没有在“语言依赖”中安装,对应任务通常会直接报缺少模块或包。 + +## 当前示例 + +目前文档已经整理出的示例类型: + +- [浏览器示例](./browser.md) +- [内置库示例](./builtin.md) +- [Linux 环境依赖示例](./linux-deps.md) diff --git a/docs/guide/examples/linux-deps.md b/docs/guide/examples/linux-deps.md new file mode 100644 index 0000000..b449c88 --- /dev/null +++ b/docs/guide/examples/linux-deps.md @@ -0,0 +1,111 @@ +# Linux 系统依赖处理 + +在使用taskpool时,您可能会在运行某些脚本时遇到缺少底层 Linux 系统级依赖(例如 `apt` 或 `apk` 包)的情况。这篇指南将详细讲解如何优雅、持久地解决这些依赖问题。 + +## 背景与痛点 + +taskpool通常以 Docker 容器的形式运行。Docker 的文件系统具有以下特性: +- **挂载目录(持久化)**:像 `data/` 这样的目录被映射到了宿主机,其中的数据(如脚本、日志、配置文件)在重启或升级镜像时会保留。 +- **容器层(非持久化)**:容器自身的系统目录(如 `/usr/bin`, `/lib`, `/etc`)是临时层。如果您直接在终端里手动执行 `apt-get install xxx`,虽然当下可以立即使用,**但在容器被销毁重建或更新镜像后,这些刚安装的系统包就会全部丢失。**(注意:仅仅是普通的 `docker restart` 重启容器并不会丢失,只有重建容器时才会重置) + +## 核心解决思路 + +为了解决依赖丢失的问题,taskpool提供了一种自动化的解决方案:**利用 `taskpool_startup`(开机触发)类型的定时任务,在面板每次启动时自动执行一段依赖安装脚本。** + +这样,无论您如何更新镜像或重启容器,系统依赖都能在面板核心服务就绪前自动被补充安装,并且对后续的普通脚本任务透明。 + +--- + +## 具体操作步骤 + +### 第一步:编写依赖安装脚本 + +首先,在您的脚本目录(通常为 `data/scripts` 下,或者您可以单独建一个 `data/scripts/deps` 目录)创建一个 Shell 脚本,例如 `install_my_deps.sh`。 + +由于taskpool的镜像目前均基于 Debian 系统,您可以直接在脚本中使用 `apt` 或 `apt-get` 命令来管理系统依赖。 + +**示例 1:安装 Puppeteer (无头浏览器) 的依赖动态库** +```bash +#!/bin/bash +# 遇到错误即停止执行 +set -e + +echo "正在检测并安装 Puppeteer 依赖..." + +# 提前 update 索引是非常重要的一步 +apt-get update +apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 + +echo "Puppeteer 依赖安装完成!" +``` + +**示例 2:安装 Python/C++ 编译所需的基础工具链** +```bash +#!/bin/bash +set -e + +apt-get update +# 安装 gcc, g++, make 以及 python3 相关的头文件 +apt-get install -y build-essential python3-dev +``` + +**示例 3:带 Hash 检查的高阶依赖安装脚本(推荐)** +此脚本利用 `/tmp` 目录和脚本自身内容的哈希值,完美匹配 Docker 容器的生命周期,避免在普通重启时无意义地检测。 +```bash +#!/bin/bash +set -e + +# 生成当前脚本的哈希标识 +SCRIPT_HASH=$(md5sum "$0" | awk '{print $1}') +FLAG_FILE="/tmp/deps_installed_${SCRIPT_HASH}" + +# 如果标识文件存在,说明在此容器生命周期内已安装过,且脚本未被修改,直接退出 +if [ -f "$FLAG_FILE" ]; then + echo "系统依赖已就绪,跳过安装。" + exit 0 +fi + +echo "开始安装系统环境依赖..." +apt-get update +# 假设您需要安装 ffmpeg 和 imagemagick +apt-get install -y ffmpeg imagemagick + +# 安装成功后写入标识文件 +touch "$FLAG_FILE" +echo "系统依赖安装完成!" +``` + +### 第二步:配置开机触发任务 + +脚本编写并保存到面板后,接下来只需将其配置为开机任务: + +1. 进入面板的 **「定时任务」** 页面,点击 **「新建任务」**。 +2. **任务名称**: 填写容易辨识的名称,例如 “安装系统底层依赖”。 +3. **执行命令**: 输入执行该脚本的命令,例如 `bash deps/install_my_deps.sh` (假设您将脚本放在了 `deps` 文件夹下)。 +4. **触发类型**: 在下拉菜单中选择 **`taskpool_startup` (开机触发)**。 +5. **保存** 任务。 + +现在,你可以尝试在终端中执行一下该任务验证脚本是否无误。一旦无误,未来每次容器重启,面板都会自动在后台静默执行这个任务,确保环境完备。 + +--- + +## 官方预设示例:PHP 编译依赖 + +为了方便用户参考,我们在项目源码中内置了一个更完善的依赖安装脚本示例。 + +通过 `mise` 安装某些 PHP 版本时,系统会尝试从源码编译,这就需要用到 `autoconf`, `bison`, `pkg-config` 等工具。 + +如果您在安装 PHP 时遇到 `autoconf not found` 或 `buildconf failed`,可以直接使用项目根目录下的预设示例脚本: +- **路径位置**: `example/deps/install_php_env_deps.sh` +- **使用方法**: 新建 `taskpool_startup` 触发类型的任务,执行命令填写 `bash example/deps/install_php_env_deps.sh` 即可。 + +此示例脚本中还包含了“检测是否已安装再决定是否执行 apt install”的逻辑,您可以查阅其源码作为编写自己依赖脚本的最佳实践参考。 + +--- + +## 注意事项与进阶建议 + +1. **幂等性 (Idempotency)**:开机脚本在每次重启时都会在后台异步执行。像 `apt-get install -y` 这种命令天然是幂等的(如果已安装就不会重新下载),虽然它不会阻塞面板的启动速度,但每次无意义地检查和刷新软件源仍会白白占用开机初期的系统资源。建议您在脚本中先用 `dpkg -l <包名>` 或 `command -v <命令>` 判断依赖是否存在,不存在时再执行安装。 + - **进阶技巧**:您也可以在依赖安装完成后,向 `/tmp` 目录下写入一个带有当前脚本内容 Hash 值的标识文件(例如 `touch /tmp/deps_installed_$(md5sum "$0" | awk '{print $1}')`)。在脚本开头判断该文件是否存在,若存在则直接退出。由于容器被销毁重建时 `/tmp` 目录和您安装的系统依赖会一并丢失,而在普通的重启中它们又会一并保留,这种方式完美契合了容器的临时层生命周期,能避免反复执行依赖检测逻辑,进一步加速开机任务。 +2. **网络环境**: Docker 镜像已经**默认将 APT 源替换为了清华源 (TUNA)**,因此在国内网络下执行 `apt-get` 也能获得很快的下载速度,您通常不需要在脚本中再次手动替换源。 +3. **避免冲突**:请仅安装您脚本运行强依赖的底层库,尽量不要通过 `apt` 安装 Node.js 或 Python 的运行环境,这些高级语言环境应交由面板的 **「编程语言」** (Mise) 模块统一管理。 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..8f5906a --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,43 @@ +# 访问面板 + +部署成功并启动容器后,您只需通过浏览器即可访问taskpool。 + +## 默认账号 + +- **访问地址**:`http://localhost:8052` (或您配置的宿主机端口) +- **用户名**:`admin` +- **密码**:首次启动成功后,系统会为管理员账号生成 **12 位随机初始密码** 并打印在容器启动日志中。 + +> **如何查找初始密码**: +> 运行容器后,在命令行执行: +> ```bash +> docker logs taskpool | grep "管理员账号创建成功" +> ``` +> 找到包含密码的内容后登录,登录后建议首选操作:**修改管理员密码**。 + +--- + +## 登录后的首要配置 + +### 1. 修改密码 +在右上角用户头像下拉菜单选择「个人设置」进行账号安全修改。 + +### 2. 系统调度设置 +在「系统设置」>「调度设置」中,可以根据服务器资源微调任务队列的并发数(默认 4)和最大队列大小(默认 100)。 + +### 3. 环境与依赖 +如果您需要执行特定语言或脚本包,请先进入「编程语言」页面确认所需的环境已安装(如已安装 Python3.x 或 Node.js.x)。 + +--- + +## 面板功能一览 + +| 模块 | 说明 | +| :--- | :--- | +| **仪表盘 (Dashboard)** | 实时监控任务执行动态、容器状态和资源占用频率情况。 | +| **定时任务 (Tasks)** | 管理和调度各种 Cron 脚本。 | +| **脚本管理 (Scripts)** | 在线编辑、上传项目源代码。 | +| **在线终端 (Terminal)** | 直接操作容器环境进行运维和调试。 | +| **消息推送 (Notify)** | 配置各类通知渠道。 | +| **环境变量 (Environments)** | 管理脚本所需的各种隐私信息、持久配置。 | +| **个人设置 (Settings)** | 调整站点 UI 和账号安全信息。 | diff --git a/docs/guide/history.md b/docs/guide/history.md new file mode 100644 index 0000000..15c55e9 --- /dev/null +++ b/docs/guide/history.md @@ -0,0 +1,24 @@ +# 执行历史 + +执行历史详细记录了面板中所有任务的运行状态、实时日志及耗时统计。 + +## 日志详情 + +- **实时日志推流**:即使任务仍在运行,也可以在历史日志详情页实时看到程序的控制台输出(stdout/stderr)。 +- **历史归档**:系统默认保留最近一段时间的任务运行快照。 +- **状态统计**: + - `SUCCESS`:任务按计划成功运行并返回正常退出代码。 + - `FAILURE`:脚本运行时报错或程序异常终止。 + - `TIMEOUT`:任务执行超出了设定的最大运行时间,由系统强制中止并标记为超时。 + +## 日志管理 + +- **搜索与过滤**:支持通过 `任务名称`、`脚本文件名` 或 `状态` 快速检索历史。 +- **自动清理策略**: + - **最大保留份数**:支持在系统设置中配置每个任务保留的历史日志最大数量。 + - **日志滚动更新**:当产生新日志且超过最大份数时,最旧的记录将被自动清除,确保存储空间的动态平衡。 + +## 执行耗时 + +- **精准计时**:精确统计每次任务执行从启动到退出的全周期耗时。 +- **性能分析**:通过历史耗时数据对比,可辅助用户排查脚本是否出现了性能退化。 diff --git a/docs/guide/interconnect.md b/docs/guide/interconnect.md new file mode 100644 index 0000000..11bb2bf --- /dev/null +++ b/docs/guide/interconnect.md @@ -0,0 +1,62 @@ +# 面板互联 (Interconnect) + +面板互联功能允许您将多个面板(taskpool)连接在一起,形成主从(Master-Child)架构的集群。这使得您可以在一个中心化的主面板上集中监控和管理所有的子节点,极大地简化了多面板环境下的运维工作。 + +## 核心特性 + +- **集中管理**:在主节点上统一查看所有子节点的状态。 +- **无缝穿越**:主节点可以直接“穿越”到子节点的控制台进行管理,无需反复登录。 +- **内网穿透**:即使子节点部署在没有公网 IP 的深层内网(如家庭宽带、企业内网),只要主节点具有公网访问能力,子节点也能主动与主节点建立安全的反向穿透隧道,实现主节点对内网子节点的直连管理。 +- **极致性能**:深度优化了底层连接与通信逻辑,严格控制并优化了协程(Goroutine)数目,确保在海量节点高并发连接下依然保持极低的资源占用和极高的系统稳定性。 +- **角色互斥**:每个面板只能扮演一种角色(主节点或子节点),避免循环嵌套。 + +## 架构说明 + +### 主节点 (Master) + +- **功能**:集中监控其他面板的状态,并可无缝穿越到子节点进行管理。 +- **适用场景**:部署在具有公网 IP 的云服务器上,作为整个集群的控制中心。 +- **配置操作**:选择作为主节点后,您可以生成专属的连接密钥,并将此密钥提供给子节点用于连接。在主节点的界面上可以添加并管理多个子节点。 + +### 子节点 (Child) + +- **功能**:向主节点报告自身的运行状态,并允许主节点穿越到本面板进行管理。 +- **适用场景**:部署在各种边缘环境,如家庭宽带、企业内网等可能没有公网 IP 的环境中。 +- **配置操作**:选择作为子节点后,需要填入主节点的地址和由主节点生成的密钥。子节点会主动发起连接,与主节点建立安全的反向穿透隧道。 + +## 使用步骤 + +1. **确定角色**:首先在您的面板集群中规划好哪台机器作为主节点,哪些机器作为子节点。 +2. **配置主节点**: + - 登录主节点的面板。 + - 导航至左侧菜单的 **面板互联**。 + - 选择 **我是主节点 (Master)** 角色。 + - 复制生成的连接信息或密钥。 +3. **配置子节点**: + - 登录子节点的面板。 + - 导航至左侧菜单的 **面板互联**。 + - 选择 **我是子节点 (Child)** 角色。 + - 填入主节点的地址和刚才复制的密钥。 + - 保存并连接。 +4. **统一管理**: + - 回到主节点,您将看到刚刚连接上来的子节点列表及其在线状态。 + - 点击子节点列表中的对应操作按钮,即可实现无缝穿越,直接管理该子节点的资源和任务。 + +## 无缝穿越功能 (Seamless Travel) + +无缝穿越是面板互联中最强大的功能之一,它允许您在不离开主节点浏览器界面的情况下,直接接管并操作任何连接的子节点。 + +### 穿越特点 + +- **免密直连**:只要子节点已经连接到主节点,即可一键穿越,无需再次输入子节点的管理员账号和密码。 +- **全功能接管**:穿越后,您看到的所有数据(如定时任务、脚本、执行历史、系统状态等)和进行的所有操作(如新建任务、执行脚本)都是针对**该子节点**的。相当于您直接在子节点本地登录。 +- **内网穿透能力**:得益于底层的反向隧道技术,即使子节点位于无法直接访问的深层内网,穿越依然能够流畅进行,所有的 API 请求都将通过隧道安全转发。 + +### 如何退出穿越 + +当您处于穿越状态时(即正在管理某个子节点),界面左下方会出现一个醒目的悬浮控制条(**“返回主节点”**)。 +- 随时点击该按钮即可**退出穿越**。 +- 退出后,您的视图和操作权限将立即恢复为主节点的本地状态。 + +> **注意**: +> 请根据实际集群架构分配角色,一旦设定角色,除非重置配置,否则该面板将一直保持此角色。在演示模式下,可能无法修改互联角色。 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md new file mode 100644 index 0000000..e52c164 --- /dev/null +++ b/docs/guide/introduction.md @@ -0,0 +1,23 @@ +# 项目介绍 + +taskpool (TaskPool) 是一款极致轻量、高性能的自动化任务调度平台。采用 Go + Vue3 架构,专注于高性能与低系统开销。 + +## 核心亮点 + +- **极致性能**:采用 Go 语言开发,在同样的任务执行下,资源占用极低。 +- **运行时解耦**:深度集成 **Mise** 运行时管理,原生支持 Python、Node.js、Go、Rust、PHP 等所有主流语言环境的动态安装(几乎所有的版本)与统一依赖管理。 +- **一键部署**:支持 Docker/Docker-Compose 一键部署,开箱即用。 +- **现代 UI**:基于 Vue3 + TailwindCSS + Shadcn/ui,提供响应式设计与深色/浅色主题。 + + +## 主要特色 + +- **轻量级:** docker/compose部署,无需复杂配置,开箱即用 +- **任务调度:** 支持标准 Cron 表达式,常用时间规则快捷选择。日志不落文件,没有磁盘频繁io的问题 +- **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压 +- **在线终端:** WebSocket 实时终端,命令执行结果实时输出 +- **消息推送:** 内置强大消息推送与通知引擎,无缝兼容主流渠道,支持系统级事件告警 +- **环境变量:** 安全存储敏感配置,任务执行时自动注入 +- **移动端:** 适配移动小屏样式 +- **远程执行:** 支持远程agent执行任务,展示执行结果 +- **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理 diff --git a/docs/guide/languages.md b/docs/guide/languages.md new file mode 100644 index 0000000..1ec474d --- /dev/null +++ b/docs/guide/languages.md @@ -0,0 +1,74 @@ +# 语言依赖 + +taskpool深度集成了 **Mise** 运行时管理器,这使得它具备多版本语言环境的高灵活性和隔离性。 + +## 脚本运行环境 + +taskpool原生支持以下脚本的定时执行: +- **Python3**, **Node.js**, **Bash** (标准版镜像内置环境) +- 通过 **Mise** 扩展:支持几乎所有主流编程语言的动态安装与切换。 + + +> [!TIP] +> **Minimal 镜像注意**:如果您使用的是 `minimal` 标签的镜像,系统初始不包含 Python 和 Node.js。您需要进入「编程语言」页面手动点击安装您所需的运行时。 + +## 依赖管理支持 + +系统内置了高度集成的跨语言依赖管理器,支持自动化安装和管理以下语言的依赖项,并确保在容器内全局可用: + +| 语言 | 包管理器 | 功能说明 | +| :--- | :--- | :--- | +| **Python** | pip | 自动使用内置虚拟环境,支持清华源 | +| **Node.js** | npm | 全局安装模式,自动配置 npmmirror 镜像 | +| **Go** | go install | 通过 `go install` 安装二进制工具 | +| **Rust** | cargo | 通过 `cargo install` 安装 Rust 依赖 | +| **Ruby** | gem | 支持 `gem install` 本地安装 | +| **Bun** | bun | 支持 `bun add -g` 全局模式 | +| **PHP** | composer | 支持 `composer global require` | +| **Deno** | deno | 支持 `deno install -g` | +| **.NET** | dotnet | 支持 `dotnet tool install -g` | +| **Elixir/Erlang** | mix | 支持 `mix archive.install` | +| **Lua** | luarocks | 通过 `luarocks` 管理 Lua 包 | +| **Nim** | nimble | 支持 `nimble install` | +| **Dart/Flutter** | pub | 支持 `pub global activate` | +| **Perl** | cpanm | 简单的 `cpanm` 安装支持 | +| **Crystal** | shards | `shards` 项目级别或工具安装 | + +## 使用方法 + +### 1. 安装环境 +进入「编程语言」页面,使用 `mise` 一键安装所需的语言及版本。 + +### 2. 依赖管理 +在已安装列表点击「依赖管理」,输入名称(可选版本)即可自动在对应环境内完成安装。 + +### 3. 多版本切换 +对于复杂的项目,您可以通过面板配置不同的任务版本镜像,系统基于 `mise exec` 实现了完善的环境隔离,不同版本的依赖包互不冲突。 + +## 常用工具安装 + +如果您需要在面板环境中使用 Ansible 或其他通过 pipx 管理的工具,可以使用以下命令进行快速安装: + +### 安装 Ansible + +taskpool推荐通过 `mise` 结合 `pipx` 安装 Ansible,以保持环境隔离且全局可用: + +```bash +# 首先安装 pipx +mise use -g pipx@latest + +# 使用 pipx 安装 ansible +mise use -g ansible@latest +``` + +安装完成后,您可以在「脚本管理」或「定时任务」中直接调用 `ansible` 或 `ansible-playbook` 命令。 + + + +--- + +## 隔离机制说明 + +- taskpool通过动态注入 `PATH` 环境和 `mise shims` 将语言环境暴露给系统。 +- 每个任务在执行前都会根据任务配置自动加载对应的运行时环境变量。 +- **运行时激活**:自动将 `MISE_DATA_DIR` 等环境变量指向宿主机的持久化挂载目录,确保护持久化可用。 diff --git a/docs/guide/nginx.md b/docs/guide/nginx.md new file mode 100644 index 0000000..d3fae08 --- /dev/null +++ b/docs/guide/nginx.md @@ -0,0 +1,86 @@ +# Nginx 反向代理配置 + +如果您需要通过域名和 HTTPS 访问taskpool,推荐使用 Nginx 作为反向代理并配置 WebSocket 负载均衡。 + +## Nginx 反向代理配置示例 + +### 1. 配置映射 +首先,在 `nginx.conf` 的 `http` 块中添加 WebSocket 升级映射: +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +``` + +--- + +### 2. 服务器配置 +将 `example.com` 替换为您的域名,并指定宿主机监听端口: +```nginx +server { + listen 443 ssl http2; + server_name example.com; + + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + access_log /var/log/nginx/example.access.log; + error_log /var/log/nginx/example.error.log warn; + + location / { + proxy_pass http://127.0.0.1:8052; # 指定taskpool宿主机 IP 和端口 + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支持(在线控制台必需) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_buffering off; + proxy_read_timeout 60s; + } +} + +# 自动 HTTP 跳转 HTTPS (可选) +server { + listen 80; + server_name example.com; + return 301 https://$server_name$request_uri; +} +``` + +--- + +### 3. 子路径部署场景 + +如果您是通过 `BH_SERVER_URL_PREFIX=/taskpool` 进行子路径托管,请修改 `location` 参数: +```nginx +location /taskpool/ { + proxy_pass http://127.0.0.1:8052/taskpool/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +--- + +## 验证与发布 + +在保存配置文件后,请执行以下命令确保 Nginx 配置正确并重启: +```bash +# 检查语法 +nginx -t +# 重启服务 +nginx -s reload +``` diff --git a/docs/guide/notify.md b/docs/guide/notify.md new file mode 100644 index 0000000..0bc8632 --- /dev/null +++ b/docs/guide/notify.md @@ -0,0 +1,132 @@ +# 消息中心 + +消息中心集成了一整套灵活且现代的消息分发引擎,支持多场景自动推送到外部 IM 工具。 + +## 消息通道 + +- **企业 IM**:支持集成 **企业微信** (WeCom)、**钉钉** (DingTalk)、**飞书** (Lark)。 +- **个人推送到位**:支持 **Telegram** Bot、**Bark** (支持自建)、**VoceChat** (支持自建) 以及基于 **Wpush** 的推送服务。 +- **公共渠道**:标准的 **SMTP 邮件** 及 **Webhook** 回调。 + +## 事件通知规则 + +- **多事件配置**:您可以灵活定义在哪些场景下触发通知,包括但不限于: + - **任务失败**:定时任务在 Cron 触发后运行报错。 + - **任务超时**:任务由于运行过长被系统中止。 + - **登录安全**:检测到异地登录或多次密码错误。 + - **服务下线**:Agent 节点掉线提醒。 + +## 推送使用路径 + +taskpool提供了两种不同层面的通知推送方式,满足从“自动报警”到“程序内自定义推送”的全场景需求。 + +--- + +### 路径一:任务绑定通知(零代码自动化) + +这是最常用的方式,用于在定时任务执行完成后,根据结果自动发送通知。 + +1. **入口**:在 **「定时任务」** 页面,点击任务右侧的 **「编辑」**。 +2. **配置**:在弹窗底部的 **「通知配置」** 栏目中: + - **选择渠道**:指定发送消息的 IM 渠道。 + - **触发时机**:勾选 `成功时`、`失败时` 或 `超时时`(建议至少勾选失败和超时)。 + - **附带日志**:开启后可在消息中直接预览报错日志,支持设置截取长度。 +3. **生效**:保存后,该任务每次运行结束都会按设定的逻辑自动推信。 + +--- + +### 路径二:脚本手动调用 (内置助手库 - 推荐) + +taskpool提供了一套**零配置**的内建助手库(Built-in SDK),支持 Python 和 Node.js。除了支持极简的消息通知投递外,它还支持管理面板的**环境变量**与**定时任务控制**。 + +#### 1. 如何获取配置 Key? +在使用助手库前,请确保您已经在任务设置的“环境变量”或“机密”中配置了以下对应 Key: +- **消息推送相关**: + - `BHPKG_NOTIFY_TOKEN`:进入「消息推送」->「脚本调用说明」标签,可以直接复制此处的 Token。 + - `BHPKG_NOTIFY_CHANNEL`:进入「消息推送」->「渠道列表」标签,可以查看每个渠道对应的 **ID**。 + - `BHPKG_NOTIFY_URL` (可选):内置通知 API 的地址。默认为 `http://localhost:8052/api/v1/notify/send`。 +- **环境与任务管理相关**: + - `BHPKG_OPENAPI_TOKEN` (或 `OPENAPI_TOKEN`):OpenAPI 鉴权 Token,在「系统设置」->「OpenAPI」中生成。 + - `BHPKG_OPENAPI_URL` (可选):默认为本地面板 API 地址。 + +#### 2. 环境初始化 +在开始编写脚本前,您需要在终端执行以下命令,为面板管理的所有语言环境安装 `taskpool` 包: + +```bash +taskpool builtininstall +``` +*该操作会将助手库安装到 mise 管理的所有版本中,确保 import 成功。* + +#### 3. 代码示例 + +##### Python (同步调用) +```python +import taskpool + +# 消息通知 +taskpool.notify("任务标题", "通知正文内容") + +# 环境变量与任务管理(详细用法见内置库示例) +envs = taskpool.get_envs() +tasks = taskpool.get_tasks() +``` + +##### Node.js (异步调用) +```javascript +const taskpool = require('taskpool'); + +// 消息通知 +taskpool.notify("任务标题", "通知正文内容"); + +// 环境变量与任务管理(详细用法见内置库示例) +(async () => { + const envs = await taskpool.getEnvs(); + const tasks = await taskpool.getTasks(); +})(); +``` + +> [!TIP] +> 关于环境变量增删改查以及任务触发控制的完整 API 列表与更详尽的代码,请参考 [内置库示例](./examples/builtin.md)。 + + +--- + +### 路径三:其他语言/高级调用 (原始 API) +> [!IMPORTANT] +> 以下示例中的端口均默认为 `8052`。如果您更改了容器内部的服务端口(通过 `BH_SERVER_PORT` 环境配置),请务必在调用时将 `8052` 替换为您的实际端口。 + +如果您使用 Shell 或其他尚未提供助手库的语言,可以通过标准 HTTP POST 请求调用。 + +#### 1. 快速获取代码 +进入 **「消息推送」** -> **「脚本调用说明」** 标签,页面会根据您的配置自动生成包含 **通知 Token** 和 **默认渠道 ID** 的完整代码。 + +#### 2. 代码参考示例 + +##### Shell (Curl) +> **注意**:如果更改了容器内部的服务端口,请将 `8052` 替换为实际端口,或直接使用环境变量 `BHPKG_NOTIFY_URL`。 + +```bash +curl -X POST "http://localhost:8052/api/v1/notify/send" \ + -H "notify-token: 您的_NOTIFY_TOKEN" \ + -d '{"channel_id":"渠道ID", "title":"标题", "text":"内容"}' +``` + +##### 基础 Python (requests) +```python +import requests + +def send_notify(title, content): + url = "http://localhost:8052/api/v1/notify/send" + headers = { "notify-token": "您的_NOTIFY_TOKEN" } + data = {"channel_id": "您的_渠道_ID", "title": title, "text": content} + requests.post(url, headers=headers, json=data) +``` + +--- + +## 消息中心管理 + +除了配置发送路径,您还可以在消息中心进行以下操作: + +- **发送记录 (审计)**:实时记录每一条通过taskpool发送至外部的消息,方便追溯。 +- **回执查询**:在 **「消息日志」** 页面查看到每条推送的详细状态,如果发送失败,会提供原始的错误响应代码以供排查。 diff --git a/docs/guide/package-stats.md b/docs/guide/package-stats.md new file mode 100644 index 0000000..ffea8b3 --- /dev/null +++ b/docs/guide/package-stats.md @@ -0,0 +1,388 @@ + + +# 镜像下载量统计 + +本页面展示 GitHub Container Registry 上任务池(`ghcr.io/engigu/taskpool`)各版本镜像的 Pull(下载)数量统计。数据在文档部署时自动更新。 + +
+ 数据更新时间: + {{ formattedUpdateTime }} +
+ +
+
+

主版本下载量趋势折线图

+
+ + + + + + + + + + + + {{ grid.label }} + + + + + + + + + {{ pt.tag }} + + + +
+ {{ activePoint.tag }} + {{ activePoint.downloads.toLocaleString() }} Pulls +
+
+
+
+
+

所有版本下载数据

+ +
+
+ + + + + + + + + + + + + + + + + + +
序号版本标签 (Tag)下载量 (Pulls)
{{ idx + 1 }}{{ item.tag }}{{ item.downloads.toLocaleString() }}
没有找到匹配的版本
+
+
+
+ + diff --git a/docs/guide/scripts.md b/docs/guide/scripts.md new file mode 100644 index 0000000..578adfe --- /dev/null +++ b/docs/guide/scripts.md @@ -0,0 +1,23 @@ +# 脚本管理 + +脚本管理提供了taskpool的在线文件资源浏览器和编辑器。 + +## 资源管理器 + +- **目录树视图**:清晰层级化展示位于 `scripts` 目录下的所有子文件夹及文件。 +- **动态预览**:支持常见的文本文件预览。 +- **文件操作**: + - **创建/重命名**:在浏览器中直接对脚本文件进行管理和修订。 + - **极速上传**:支持直接在 Web 端上传单文件或进行多选拖拽上传。 + - **在线解包**:支持一键解压缩 `.zip` 包,大大优化了脚本部署流程。 + +## 在线编辑器 + +- **语法高亮**:集成了现代代码编辑器,完美支持 JavaScript、Python、Shell、Go、TypeScript 等主流开发语言。 +- **编辑器增强**:支持常见的代码查找与替换、自动缩进和括号匹配。 +- **一键保存**:编辑后的内容将实时写入服务器端物理存储,配合 `定时任务` 可快速生效。 + +## 权限控制 + +- **安全防御**:默认只能在指定的 `scripts` 根路径内进行相关文件操作,防止跨目录读取系统敏感文件。 +- **文件保护**:系统核心配置文件不可在脚本管理器中直接修改。 diff --git a/docs/guide/sync.md b/docs/guide/sync.md new file mode 100644 index 0000000..f798049 --- /dev/null +++ b/docs/guide/sync.md @@ -0,0 +1,25 @@ +# 仓库同步 (Repo) + +仓库同步允许taskpool直接以 Git 仓库的形式管理和更新脚本库,极大地方便了脚本的大规模分发与自动化部署。 + +## 同步源管理 + +- **青龙 (QL) 指令解析**:如果您曾经是青龙面板的用户,您可以直接粘贴类似的 `ql repo ` 指令,系统将自动提取各项参数。 + > [!IMPORTANT] + > **依赖管理说明**:由于该面板采用基于 Mise 的多版本语言管理系统,与青龙的全局环境不同,系统 **无法通过 `dependence` 字段自动安装依赖**。用户需要手动前往「语言依赖」页面,或者在终端中自己执行依赖,在对应的运行中安装脚本所需的依赖包。 +- **Git 源管理**:支持从 **GitHub**, **GitLab**, **Gitee** 等主流代码托管平台同步脚本。 +- **SSH/Token 访问**:支持私有仓库的访问,可以在环境变量中配置对应的 Git 鉴权秘钥。 + +## 扫描与注册规则 + +- **自动解析配置**:在同步代码至本地物理磁盘后,面板将深度扫描每个 `.js` 或 `.py` 文件。 +- **配置探测**: + - `new Env('任务名称')`:解析 JavaScript 脚本定义的展示名。 + - `cron "0 0 * * *"`:自动提取文件头部的 Cron 注释规则。 +- **白名单/黑名单**:通过正则表达式(Regex)过滤哪些子目录或特定命名的文件需要被注册为定时任务。 + +## 增量同步 + +- **Git 离线拉取**:支持增量更新,仅下载变更部分,降低带宽压力。 +- **分支切换**:支持指定任意分支进行同步,方便用户在生产与测试环境间切换脚本源。 +- **稀疏检出 (Sparse Checkout)**:如果仓库过于庞大,您可以配置仅同步特定的子文件夹以节省存储空间。 diff --git a/docs/guide/tasks.md b/docs/guide/tasks.md new file mode 100644 index 0000000..17cac04 --- /dev/null +++ b/docs/guide/tasks.md @@ -0,0 +1,36 @@ +# 定时任务 + +定时任务是taskpool的核心模块,支持对各类多语言脚本、命令进行精细化执行管理。 + +## 任务属性 + +- **任务名称**:给任务起一个直观的名称,例如 `每日签到任务`。 +- **Cron 表达式**:支持标准 cron 规则(分、时、日、月、周)。 +- **脚本路径**:关联到 `scripts` 目录下的具体脚本文件或直接输入 Shell 命令。 +- **执行终端**:允许选择运行在 `本机` 或是指定的 `远程 Agent` 节点。 +- **任务超时**:设定单次运行的最大时长,防止僵尸进程占用资源。 + +## 管理操作 + +- **启动/停止**:手动控制任务的状态,支持一键切换自动调度与临时暂停。 +- **立即执行**:不等待 Cron 触发,即刻拉起脚本运行。 +- **查看日志**:直接跳转到与该任务关联的最新执行历史详情。 +- **批量管理**:支持对选中的多个任务执行批量禁用、启用或删除动作。 + +## 交互设计 + +- **预设 Cron 规则**:在编辑任务时,提供常用的 `每分钟执行`、`每小时整点` 等预设样式。 +- **下次触发预测**:实时计算并展示任务下一次执行的北京时间,帮助验证调度逻辑是否符合预期。 + +## 特殊任务类型 + +除了标准的 Cron 定时触发,taskpool还支持以下特殊触发场景: + +### 开机启动任务 (`taskpool_startup`) + +当您在定时规则(Schedule)中填写 `taskpool_startup` 时,该任务将被标记为**系统启动任务**。 +- **触发时机**: 面板主进程启动或重启完成后立即执行。 +- **应用场景**: + - 自动挂载磁盘或网络共享。 + - **环境预热**: 例如安装 PHP 编译依赖(参考 [PHP 编译依赖说明](languages.md#php-环境特别说明))。建议命令: `bash example/deps/install_php_env_deps.sh` + - 启动自定义的后台常驻服务。 diff --git a/docs/guide/terminal.md b/docs/guide/terminal.md new file mode 100644 index 0000000..ac6f8fe --- /dev/null +++ b/docs/guide/terminal.md @@ -0,0 +1,15 @@ +# 终端命令 + +终端命令模块(Terminal)允许用户在 Web 端直接与服务器的 Shell 环境进行交互。 + +## 实时交互 + +- **WebSocket 双工连接**:不仅是单向的命令发送,您可以获得一个可以输入交互(如 `yes/no` 確認、`npm init` 交互等)的伪终端(PTY)。 +- **实时输出回传**:秒级显示命令的执行结果,支持 ANSI 转义序列以正确渲染终端样式与彩色文本。 +- **自定义工作目录**:可以选择在哪一个文件夹(如 `scripts` 或系统根目录)下启动终端。 + + +## 常用指令 + +- `ls -la`:查看当前目录详细文件列表。 +- `git status`:在 `scripts` 目录下查看仓库 Git 定位状态。 diff --git a/docs/guide/usage.md b/docs/guide/usage.md new file mode 100644 index 0000000..a6bdc7c --- /dev/null +++ b/docs/guide/usage.md @@ -0,0 +1,62 @@ +# 功能特性 + +taskpool不仅提供基础的脚本执行功能,还集成了众多的实用工具和管理模块。 + +## 数据仪表 + +- **运行状态概览**:实时展示系统运行状态、任务执行统计及资源消耗。 +- **动态图表**:通过直观的图表展示任务成功率、并发趋势及系统负载。 + +## 定时任务管理 + +- **标准 Cron 表达式**:支持高度灵活的调度配置。 +- **控制台快捷键**:常用规则一键选择。 +- **手动触发执行**:支持临时执行任务。 +- **任务超时控制**:通过配置 `timeout` 参数,系统会自动隔离并中止长时间运行的任务。 + +## 远程分布式执行 (Agents) + +- **子节点管理**:支持注册多个远程 Agent 节点,实现分布式任务分发。 +- **跨平台支持**:Agent 可部署在 Linux、Windows、macOS 等不同系统,覆盖异构执行环境。 + +## 脚本文件管理 + +- **在线代码编辑器**:集成了现代代码编辑器,支持语法高亮和编辑。 +- **文件树形结构**:直观展示项目内所有文件。 +- **文件上传与解压**:支持单文件、多文件上传和对 ZIP 压缩包的在线解压。 +- **文件管理**:支持在线进行创建、重命名、移动和删除操作。 + +## 在线终端 + +- **WebSocket 实时终端**:支持常用的 Shell 命令。 +- **命令输出实时推流**:实时查看脚本运行的物理设备输出。 + +## 执行日志 + +- **任务执行历史**:记录每次运行的状态(成功/失败/超时)。 +- **执行耗时统计**:自动统计任务耗时,辅助性能优化。 +- **日志压缩存储**:通过对旧日志进行自动清理和压缩,规避存储空间占用问题。 + +## 环境变量管理 (Secret) + +- **机密性管理**:对敏感字段(如脚本 Key、DB 密码)进行脱敏显示和加密存储。 +- **全局环境隔离**:在不同脚本运行期间动态注入,确保持久化和隔离。 + +## 消息推送与系统通知 + +- **原生内置分发**:集成了企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道。 +- **多事件灵活通知**:您可以配置「任务失败」、「服务下线」、「登录安全报警」等事件通知条件。 +- **API 示例**:系统自动生成各种编程语言的一键集成代码片段,方便用户脚本集成。 +- **消息日志**:详细记录每条推送消息的状态、接收人和尝试发送的日志,方便故障排查。 + +## 仓库任务同步 + +- **青龙指令兼容**:支持直接粘贴 `ql repo` 指令快速创建同步任务。 +- **脚本自动注册**:自动扫描同步目录下的脚本文件,解析其中的 `new Env()` 名称和 `cron` 表达式。 +- **灵活筛选规则**:支持通过正则表达式配置白名单、黑名单,精确控制哪些脚本需要转化为面板任务。 +- **版本控制集成**:基于 Git 进行增量同步,支持分支切换和稀疏检出(Sparse Checkout)。 + +## 系统设置 + +- **数据备份与恢复**:支持全量数据的本地导出和一键导入恢复。 +- **页面设置**:自定义站点标题、标语和分页显示逻辑。 diff --git a/docs/guide/webui.md b/docs/guide/webui.md new file mode 100644 index 0000000..eb1bec4 --- /dev/null +++ b/docs/guide/webui.md @@ -0,0 +1,134 @@ +# 前端定制 (WebUI) + +taskpool支持完全接管和替换默认系统面板界面。你可以开发自己专属的前端主题,甚至添加自定义的前端交互功能,并打包为独立的 WebUI 资源包上传至系统应用。 + +> [!IMPORTANT] +> **安全与一致性维护声明**: +> 更换前端包后,系统无法自动保障自定义前端的安全性,亦无法确保其与后续更新的后端 API 接口始终保持一致。**自定义前端包的更新、向后兼容维护与漏洞修复需完全由该前端资源提供者(或开发者)负责**。 + +--- + +## 快速使用 + +### 1. 网页端上传与切换 +1. 进入系统后,点击导航栏的 **系统设置**。 +2. 切换到 **前端定制** 面板。 +3. 点击右上角的 **上传前端资源包**,选择你打包好的 `.zip`、`.tar.gz` 或 `.tgz` 格式的前端资源包。 +4. 上传成功后,列表会显示该包的信息(名称、版本、作者、状态等)。 +5. 点击操作栏中的 **启用** 按钮,系统将自动重载并切换至你的自定义前端包。 + +> [!WARNING] +> 自定义前端包若存在 Bug 或打包不完整可能导致界面白屏。如果不慎应用了错误或不兼容的包,请使用下方命令行工具恢复。 + +### 2. 命令行 (CLI) 运维 +当界面因异常白屏无法访问时,可以进入taskpool容器/服务器终端,使用 `taskpool webui` 命令一键管理或恢复: + +- **一键恢复默认内置界面**: + ```bash + taskpool webui reset + ``` +- **查看已安装的资源包列表**: + ```bash + taskpool webui list + ``` +- **手动切换/启用前端包**: + ```bash + taskpool webui set <包名> + ``` +- **删除指定的前端包**: + ```bash + taskpool webui delete <包名> + ``` + +--- + +## 开发自定义前端 + +taskpool的前端架构是完全解耦的。你可以选择以下两种方式之一来定制专属前端: + +### 方式一:基于现有代码二次开发(推荐) +如果你只是想修改部分样式、布局,或者在原有功能基础上增加新特性,最简单的方式是 **Fork `taskpool` 项目**。 +1. Fork 本项目并克隆代码到本地。 +2. 直接在项目的 `web/` 目录下,对现有的 Vue3 源码进行修改与定制。 +3. 修改完成后,利用项目自带的 Makefile 打包命令(见下方说明)一键将你的修改编译为独立的 WebUI 资源包。 + +### 方式二:从零开始全新开发 +如果你想用自己熟悉的技术栈(如 React, Angular,或者是纯静态的 HTML/JS)完全重写整个面板,这也是完全支持的!你只需要按照下方的核心规范进行开发和打包即可。 +### 1. 核心校验规则 +taskpool后端提取并启用前端资源时,会执行以下强校验: +1. **压缩包根目录下必须包含 `index.html`**:作为单页应用 (SPA) 的静态入口文件。 +2. **压缩包根目录下必须包含 `uimanifest.json`**:声明该前端包的元数据信息。 + +### 2. 配置文件 `uimanifest.json` 规范 +在前端打包产物的根目录下(与 `index.html` 同级),必须创建一个 `uimanifest.json` 文件。格式示例如下: + +```json +{ + "name": "custom-neon-theme", + "version": "1.0.2", + "author": "YourName", + "description": "taskpool霓虹暗黑风定制前端主题", + "min_panel_version": "1.0.0" +} +``` + +*注:`name` 字段不能设置为 `"default"`(default 被保留作为内置前端的系统标识)。* + +### 3. API 请求地址与开发环境代理 +在独立开发自定义前端时,需要配置请求与后端的通信地址及代理: + +- **后端默认服务地址与端口**: + taskpool后端服务默认运行在端口 `8052` 上,本地调试 API 的基础 URL 通常为: + `http://127.0.0.1:8052/api/v1` + +- **本地开发环境代理配置(以 Vite 为例)**: + 为了避免跨域问题(CORS),推荐在前端开发服务器中设置代理。在 `vite.config.ts` 中配置示例如下: + ```typescript + export default defineConfig({ + server: { + proxy: { + '/api/v1': { + target: 'http://127.0.0.1:8052', // 本地运行的taskpool后端地址 + changeOrigin: true + } + } + } + }) + ``` + +- **生产环境线上适配(相对路径)**: + 前端包部署生效后,与后端处于同端口同域名下。后端会在返回的 `index.html` 的 `` 中自动注入以下配置变量: + ```html + + ``` + 建议在封装 Axios 或 Fetch 时,直接通过浏览器环境变量拼接相对路径作为 API 地址: + ```typescript + const baseURL = `${window.location.origin}${window.__BASE_URL__ || ''}${window.__API_VERSION__ || '/api/v1'}`; + ``` + +- **接口定义与类型参考**: + 默认系统中已经定义好了所有的后端 API 接口签名、传参格式以及 TS 类型声明。你在二次开发或自定义前端时,可以直接参考项目源码中的前端接口定义文件: `web/src/api/index.ts`。 + +--- + +## 打包前端资源包(现成) + +你可以利用taskpool项目自带的 `Makefile` 脚本,在现在的前端页面进行修改,将开发好的前端项目快速编译打包成标准的 `.tar.gz` 前端资源包, 自己使用或者分享使用。 + +### 使用 Makefile 打包 + +在项目根目录下,运行以下指令(参数必须填写完整): + +```bash +make pack-webui NAME=neon-theme VERSION=1.0.2 AUTHOR=MyName DESC="霓虹定制主题包" +``` + +该指令会自动执行以下步骤: +1. 进入 `web/` 目录并安装依赖; +2. 编译构建前端静态资源(默认输出到 `web/dist`); +3. 在 `web/dist` 中自动按参数生成校验所需的 `uimanifest.json`; +4. 将该目录下所有文件使用 `tar` 命令进行 gzip 压缩打包; +5. 输出归档文件在项目根目录的 `bin/webui-neon-theme-1.0.2.tar.gz`,此包即可直接在面板中上传安装。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0ff4c81 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,39 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "taskpool" + text: "极致轻量、高性能的自动化任务调度平台" + tagline: "采用 Go + Vue3 架构,专注于高性能与低系统开销。" + image: + src: /logo.svg + alt: TaskPool Logo + actions: + - theme: brand + text: 快速开始 + link: /guide/introduction + - theme: alt + text: 查看源码 + link: https://github.com/engigu/taskpool + +features: + - title: 极致轻量 + details: Docker/Compose 一键部署,无需复杂配置,开箱即用,资源分配合理。 + icon: '' + - title: 任务调度 + details: 支持标准 Cron 表达式,日志不落文件,规避频繁磁盘 IO 问题。 + icon: '' + - title: 多语言支持 + details: 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理。 + icon: '' + - title: 在线管理 + details: 现代响应式 UI,集成在线编辑器、实时终端与 WebSocket 日志流。 + icon: '' + - title: 消息推送 + details: 内置主流推送渠道(微信、钉钉、飞书、Telegram 等),支持系统级事件通知。 + icon: '' + - title: 安全稳健 + details: 安全存储敏感配置,任务自动注入,登录防暴力破解,精细权限定制。 + icon: '' +--- diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..ae70d76 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,5711 @@ +{ + "name": "taskpool-docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskpool-docs", + "version": "1.0.0", + "dependencies": { + "@scalar/api-reference": "^1.48.2", + "dompurify": "^3.4.8", + "fast-uri": "^3.1.2", + "postcss": "^8.5.15" + }, + "devDependencies": { + "vitepress": "^1.6.4" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.13.tgz", + "integrity": "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.5", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.2.tgz", + "integrity": "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.5.tgz", + "integrity": "sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.2", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/vue": { + "version": "3.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-3.0.33.tgz", + "integrity": "sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.5", + "ai": "6.0.33", + "swrv": "^1.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.1.tgz", + "integrity": "sha512-Wia5/mNTfiU0PIUN25UMfAGGdASkkwuCS9nBAdmhqrNPY/ff7U/6MgBVdwFDPsa3sA1msutPtO50gvOzx6MOXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.55.1.tgz", + "integrity": "sha512-miW8RzAtBgNiEJ9fGEhsOPgWUpekAe64YcVufqXrlykj0Jjmo5nj0a5f/HAzRVX5ZuU1GAVd7BkzFDx7q50P3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.55.1.tgz", + "integrity": "sha512-eR3J3kB9JX6DdCvDRi3I4KPfwO6fR9HWYRXhVke2TXIoOQafMKCRAneg33JRmIrb+DnnJ/eWApJLF1O1CLPERg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.55.1.tgz", + "integrity": "sha512-P5ak7EurwYqgAiDyb95mgA3WRR/Zu8CPMv36lWTISvL2AmlPyqQPy2nX/KEJRTcwaeTWwrk6wJV4/M93GfjOWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.55.1.tgz", + "integrity": "sha512-OVtj9uA//+pjvKQI5INnzbyLrf3ClNv3XRbWswwJ2kHIStQNHtBfHo+LofNB/WhM9xjuXlW5ANn2aMj65UGx7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.55.1.tgz", + "integrity": "sha512-oKlVFlp+qbIEe4p7E54zSiP2gEV/vDu972Ykv8VDMFwEvreS7m0YKA3a8hGGHwc7yiBUGGiR3LlwzMLfnJmy6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.1.tgz", + "integrity": "sha512-BOVrld6vdtsFmotVDMTVQfYXwrVplJ+DUvy60JFi+tkWV698q2J9NNPKEO3dr5qxtSLKQP4vHF8n+3U5PDWhOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.55.1.tgz", + "integrity": "sha512-GAqHl9zERhC3bbBfubwUu07G3UXO06gORvOcsiTBZB3et0s3auNUbHlYdYNp4VKa3sUZqH5AcD3OKzU/KDGXjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.55.1.tgz", + "integrity": "sha512-BXZw+C+gsWL7pZvbnhJUnCXASiDLGcQxVV7h55Pyh2DmSzwdZIVccE5xc9RVD2trtrhIqk5smuODTxtaZqd0IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.55.1.tgz", + "integrity": "sha512-9g/ceZrZTqA62FA3588Xj0onRPjDNfu0pVQqefK0rrHp9H6Wblph/YmzGjZ2g8uqbTh0ZGIvAGCzErU8f7MHpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.55.1.tgz", + "integrity": "sha512-cZTIrGyAP+W4A6jDVwvWM/JOaoJKQkD/2a5eLUEeNdKAD45jN7BCpsMDONyhZlosLa4UwL8uiINQzj4iFy9nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.1.tgz", + "integrity": "sha512-N6I3leW0UO8Y9Zv90yo2UHgYGuxZO0mjbvzNxDIJDjO0qECEF7Z9XMvSNeUWXQh/iNDA9lr8MfEy3rmZGIcclw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.55.1.tgz", + "integrity": "sha512-ukU5zeeFs44rQkzv+TRdYard+d+3lmPGs8lPZhHtWE8rfz+LlBSF6s9kP3VQ7LeOYL8Dz0u6tZfnyTrqrumbHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.55.1.tgz", + "integrity": "sha512-lCwXyijwPm3vbYHpBXPRomMcD6mgiptmps27gnMCf4HK+u/AOeFPBnIFh4V3l4A5SnP9VRiKBZqwGBpUH0vaTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", + "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.4", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.4.tgz", + "integrity": "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/core/node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.9.tgz", + "integrity": "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@floating-ui/utils": "^0.2.10", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@headlessui/tailwindcss": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz", + "integrity": "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "tailwindcss": "^3.0 || ^4.0" + } + }, + "node_modules/@headlessui/vue": { + "version": "1.7.23", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", + "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==", + "license": "MIT", + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.88", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.88.tgz", + "integrity": "sha512-+cvi1qCuvReL29ehi6t62L4fb7GDXe+UlGHFcsJcV7I2l9wtqn9XE2IBKcDr3CI5iGUGS5ISnXv699pSGpyx1Q==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz", + "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@phosphor-icons/core": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@phosphor-icons/core/-/core-2.1.1.tgz", + "integrity": "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==", + "license": "MIT" + }, + "node_modules/@replit/codemirror-css-color-picker": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-css-color-picker/-/codemirror-css-color-picker-6.3.0.tgz", + "integrity": "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A==", + "license": "MIT", + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scalar/agent-chat": { + "version": "0.12.13", + "resolved": "https://registry.npmjs.org/@scalar/agent-chat/-/agent-chat-0.12.13.tgz", + "integrity": "sha512-JPjwnOqIRb/TF+0bVNL7rg9SSjce3p0G++GF8uNvoGthixWuQxzo2pems4npZwe6Aw57icRTsZ3GBFgs9VmJvw==", + "license": "MIT", + "dependencies": { + "@ai-sdk/vue": "3.0.33", + "@scalar/api-client": "3.12.0", + "@scalar/components": "0.27.4", + "@scalar/helpers": "0.9.0", + "@scalar/icons": "0.7.3", + "@scalar/json-magic": "0.12.17", + "@scalar/openapi-types": "0.9.1", + "@scalar/schemas": "0.7.0", + "@scalar/themes": "0.16.2", + "@scalar/types": "0.16.0", + "@scalar/use-toasts": "0.10.2", + "@scalar/validation": "0.6.0", + "@scalar/workspace-store": "0.55.0", + "@vueuse/core": "13.9.0", + "ai": "6.0.33", + "js-base64": "^3.7.8", + "neverpanic": "0.0.8", + "truncate-json": "3.0.1", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-client": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@scalar/api-client/-/api-client-3.12.0.tgz", + "integrity": "sha512-NBZTMuqG9Qu+C0bUtMJPQEbGBLC5skXj55A/oDSzPwvTmWKV6I9Xc4wcrqVZMMidx315uC/WWyrB9WCUMgNi3Q==", + "license": "MIT", + "dependencies": { + "@headlessui/tailwindcss": "^0.2.2", + "@headlessui/vue": "1.7.23", + "@scalar/blocks": "0.1.0", + "@scalar/components": "0.27.4", + "@scalar/helpers": "0.9.0", + "@scalar/icons": "0.7.3", + "@scalar/oas-utils": "0.19.1", + "@scalar/openapi-types": "0.9.1", + "@scalar/sidebar": "0.9.25", + "@scalar/snippetz": "0.9.19", + "@scalar/themes": "0.16.2", + "@scalar/typebox": "^0.1.3", + "@scalar/types": "0.16.0", + "@scalar/use-codemirror": "0.14.12", + "@scalar/use-hooks": "0.4.7", + "@scalar/use-toasts": "0.10.2", + "@scalar/workspace-store": "0.55.0", + "@vueuse/core": "13.9.0", + "@vueuse/integrations": "13.9.0", + "focus-trap": "^7.8.0", + "fuse.js": "^7.1.0", + "js-base64": "^3.7.8", + "jsonc-parser": "3.3.1", + "nanoid": "^5.1.6", + "pretty-ms": "^9.3.0", + "radix-vue": "^1.9.17", + "set-cookie-parser": "3.1.0", + "vue": "^3.5.30", + "yaml": "^2.8.3", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/api-reference": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@scalar/api-reference/-/api-reference-1.62.0.tgz", + "integrity": "sha512-QYPqMPfy4vyVLysHeSFZsD+717ua/gHIhYYu7UjqPzJUOVh2R+/9hINKKzvoP13lQWCQTmuzs2VEl8rQOIfDjw==", + "license": "MIT", + "dependencies": { + "@headlessui/vue": "1.7.23", + "@scalar/agent-chat": "0.12.13", + "@scalar/api-client": "3.12.0", + "@scalar/blocks": "0.1.0", + "@scalar/code-highlight": "0.4.0", + "@scalar/components": "0.27.4", + "@scalar/helpers": "0.9.0", + "@scalar/icons": "0.7.3", + "@scalar/oas-utils": "0.19.1", + "@scalar/schemas": "0.7.0", + "@scalar/sidebar": "0.9.25", + "@scalar/snippetz": "0.9.19", + "@scalar/themes": "0.16.2", + "@scalar/types": "0.16.0", + "@scalar/use-hooks": "0.4.7", + "@scalar/use-toasts": "0.10.2", + "@scalar/validation": "0.6.0", + "@scalar/workspace-store": "0.55.0", + "@unhead/vue": "^2.1.4", + "@vueuse/core": "13.9.0", + "fuse.js": "^7.1.0", + "microdiff": "^1.5.0", + "nanoid": "^5.1.6", + "vue": "^3.5.30", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/blocks": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@scalar/blocks/-/blocks-0.1.0.tgz", + "integrity": "sha512-oFnIa6dAkCt0N5yw5aqCABbP7r7YsI7ERNrGtlTDdrCteDRatMJg9y03LT+9Vhf2/K3VHWrdCZMruUdfU1uPzQ==", + "license": "MIT", + "dependencies": { + "@scalar/components": "0.27.4", + "@scalar/helpers": "0.9.0", + "@scalar/icons": "0.7.3", + "@scalar/snippetz": "0.9.19", + "@scalar/themes": "0.16.2", + "@scalar/types": "0.16.0", + "@scalar/workspace-store": "0.55.0", + "@types/har-format": "^1.2.16", + "js-base64": "^3.7.8", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/code-highlight": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@scalar/code-highlight/-/code-highlight-0.4.0.tgz", + "integrity": "sha512-ajUQ9oq5MwVHrXGze0SZVatgSXP/WbN4qgE0usYDFt1XuJ+O56TaBsCWb043r3Y9ZnHSnK9GHreVaLOxXsbf1A==", + "license": "MIT", + "dependencies": { + "hast-util-to-text": "^4.0.2", + "highlight.js": "^11.11.1", + "lowlight": "^3.3.0", + "rehype-external-links": "^3.0.0", + "rehype-format": "^5.0.1", + "rehype-parse": "^9.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-stringify": "^11.0.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/components": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@scalar/components/-/components-0.27.4.tgz", + "integrity": "sha512-1sbsMYGJcWiQ+5GouI1hHCn1VrytHPhzhHhCAZTXxi1Tw4SzsPSQuACARehXca+w2p7RK9ayGRYQ+K5bX18SaQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "0.2.10", + "@floating-ui/vue": "1.1.9", + "@headlessui/tailwindcss": "^0.2.2", + "@headlessui/vue": "1.7.23", + "@scalar/code-highlight": "0.4.0", + "@scalar/helpers": "0.9.0", + "@scalar/icons": "0.7.3", + "@scalar/themes": "0.16.2", + "@scalar/use-hooks": "0.4.7", + "@vueuse/core": "13.9.0", + "cva": "1.0.0-beta.4", + "radix-vue": "^1.9.17", + "vue": "^3.5.30", + "vue-component-type-helpers": "^3.2.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.0.tgz", + "integrity": "sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/icons": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@scalar/icons/-/icons-0.7.3.tgz", + "integrity": "sha512-5uSUvumj6yJEAZT7/MKpgrkNl76waDXUpu0kUBBFJ83GhZirBIK+Z9SktShEvJT0+rk/j9Zaer3BHoEhYwAEBQ==", + "license": "MIT", + "dependencies": { + "@phosphor-icons/core": "^2.1.1", + "@types/node": "^24.1.0", + "chalk": "^5.6.2", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/json-magic": { + "version": "0.12.17", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.12.17.tgz", + "integrity": "sha512-Vw2nrUDIjhvMP6vxFtkiiFlabJ6SyTtfn1BsOxgnr1hIB+/rkngMguiDzl5em21VjyfFGIoADia+QWKM2hdcdA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.0", + "pathe": "^2.0.3", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/oas-utils": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@scalar/oas-utils/-/oas-utils-0.19.1.tgz", + "integrity": "sha512-FflVWiFDm0s4a+FqKJ0uYsVhDzbAtVyXSyUWsGe9BGPPQSXYYvVEVp9H2ir4xueaw4TZb1cDEZbteHLx7h7vqg==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.0", + "@scalar/themes": "0.16.2", + "@scalar/types": "0.16.0", + "@scalar/workspace-store": "0.55.0", + "flatted": "^3.4.0", + "vue": "^3.5.30", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.9.1.tgz", + "integrity": "sha512-gkGhSkxSzADaBiNg+ZAbJuwj+ZUmzP2Pg9CWZ7ZP+0fck2WjPeDDM7aAbouAm0aQQMF9xBjSPXSA9a/qTHYaTw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-upgrader": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.9.tgz", + "integrity": "sha512-D5b0rGLLZgmkO9mdW2j/ND1KBlH1u3RCpr87HPxv9P9ZSr6PtM5iLqFOJq0ACiaHjY2mikCrxgDmnUEhTzRpHQ==", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.9.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.0.tgz", + "integrity": "sha512-Roj0e7S29yTbGojwdx/b8C1H5arVN4h/VSPrtQ5vb9NT2ZAbrfPQVmpQYrF845rKOWX7kSzIvyBwrKNEMIAkxA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.0", + "@scalar/validation": "0.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/sidebar": { + "version": "0.9.25", + "resolved": "https://registry.npmjs.org/@scalar/sidebar/-/sidebar-0.9.25.tgz", + "integrity": "sha512-RoMKTvCo801kj5J59Myqhc6YYtvjlXGNvP7qbj/TY2hfQ4XS5dH5S7t8JKJje+78MLC2Xg9OsEmCjv+gb8ta4w==", + "license": "MIT", + "dependencies": { + "@scalar/components": "0.27.4", + "@scalar/helpers": "0.9.0", + "@scalar/icons": "0.7.3", + "@scalar/themes": "0.16.2", + "@scalar/use-hooks": "0.4.7", + "@scalar/workspace-store": "0.55.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/snippetz": { + "version": "0.9.19", + "resolved": "https://registry.npmjs.org/@scalar/snippetz/-/snippetz-0.9.19.tgz", + "integrity": "sha512-RVTZQqUxOiBhAuPywDmser1xOatVj7HCNmg+qpW8172MsMpx00lRGZ940WVAhjHgKglTkwHPaTdVX7gQeDrFUQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.0", + "@scalar/types": "0.16.0", + "js-base64": "^3.7.8", + "stringify-object": "^6.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/themes": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@scalar/themes/-/themes-0.16.2.tgz", + "integrity": "sha512-4mAn7z2W5/ASi1OF06dqf04xjxTHnMzESC2E+qVMukJsEsV4kDlYSIfm/g5hwWxhqWsBMjorF9Dtlqd0ycJ92g==", + "license": "MIT", + "dependencies": { + "nanoid": "^5.1.6" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/typebox": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@scalar/typebox/-/typebox-0.1.3.tgz", + "integrity": "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg==", + "license": "MIT" + }, + "node_modules/@scalar/types": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.0.tgz", + "integrity": "sha512-26OSrvZjKWZ7F236wWmJajBGDVFJuvXFJqKPFqbt/PxlgZKXQXfJsZorASRQmdNogT576nxYalQ7oaYWEnQwfw==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.0", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-codemirror": { + "version": "0.14.12", + "resolved": "https://registry.npmjs.org/@scalar/use-codemirror/-/use-codemirror-0.14.12.tgz", + "integrity": "sha512-m+0tmVOHZVAybbcNEizR3m9RYOLtU59AStRkk29J5qaW4J+tsjrz4BDMceeTpoVawL/lxp2vDvQR+w/gKC3miQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.8", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.10.7", + "@codemirror/lint": "^6.8.4", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/common": "^1.2.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-css-color-picker": "^6.3.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-hooks": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@scalar/use-hooks/-/use-hooks-0.4.7.tgz", + "integrity": "sha512-8zajxhnKMJuO1HF36y8TeVuSIol2pueYdRvITZTEY8TuRbnwxrvJZk25II7YpxGMORAEDr0bwNF88Dr+QUfw1w==", + "license": "MIT", + "dependencies": { + "@scalar/use-toasts": "0.10.2", + "@scalar/validation": "0.6.0", + "@vueuse/core": "13.9.0", + "cva": "1.0.0-beta.4", + "tailwind-merge": "3.5.0", + "vue": "^3.5.30" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/use-toasts": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@scalar/use-toasts/-/use-toasts-0.10.2.tgz", + "integrity": "sha512-1iHQFbDXv0YQRp13aa63S5EcTJ5K8T0ocnLxk+nziloPrLjKt6jdRt6vOHsLSv5sm9kFKcVKNQTQgialmKCOGA==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.30", + "vue-sonner": "^1.3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.0.tgz", + "integrity": "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/workspace-store": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@scalar/workspace-store/-/workspace-store-0.55.0.tgz", + "integrity": "sha512-4EkaLfONW8qu2vigJopbjg9ahE/Z9Q+IInnVoUTZfxLp4rb2Z3ztx8LmQOym8DPpv62BvuMtJcNdQOnQ6tVLHA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.0", + "@scalar/json-magic": "0.12.17", + "@scalar/openapi-upgrader": "0.2.9", + "@scalar/schemas": "0.7.0", + "@scalar/snippetz": "0.9.19", + "@scalar/typebox": "0.1.3", + "@scalar/types": "0.16.0", + "@scalar/validation": "0.6.0", + "js-base64": "^3.7.8", + "type-fest": "^5.3.1", + "vue": "^3.5.30", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.2", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz", + "integrity": "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.30", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.30.tgz", + "integrity": "sha512-5IpTZf5US81z9CHaRm2imVC44WDU1V/pd8mN1OIvIerRmo6C179UN+vnzlO/dx9Fpt9X7Rjl7fyvolPhPgtfqg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/@unhead/vue": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.15.tgz", + "integrity": "sha512-SSByXfEjhzPn8gXdEdgpYqpLMPSkLUH2HVE0GxZfOtNsJ0GgOHQs0g9T67ZZ1z0kTELLKdtOtYrzrbv9+ffF7g==", + "license": "MIT", + "dependencies": { + "hookable": "^6.0.1", + "unhead": "2.1.15" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + }, + "peerDependencies": { + "vue": ">=3.5.18" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-kit/node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-13.9.0.tgz", + "integrity": "sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/ai": { + "version": "6.0.33", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.33.tgz", + "integrity": "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.13", + "@ai-sdk/provider": "3.0.2", + "@ai-sdk/provider-utils": "4.0.5", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/algoliasearch": { + "version": "5.55.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.55.1.tgz", + "integrity": "sha512-FyaFnnsbVPtevQwqSj/SdxE3jAsSsY0BEH8IVLf9rXxEBdAhAmT6VKCVSMWoaPIHVN1Eufh/1w8q6k8URpIkWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.21.1", + "@algolia/client-abtesting": "5.55.1", + "@algolia/client-analytics": "5.55.1", + "@algolia/client-common": "5.55.1", + "@algolia/client-insights": "5.55.1", + "@algolia/client-personalization": "5.55.1", + "@algolia/client-query-suggestions": "5.55.1", + "@algolia/client-search": "5.55.1", + "@algolia/ingestion": "1.55.1", + "@algolia/monitoring": "1.55.1", + "@algolia/recommend": "5.55.1", + "@algolia/requester-browser-xhr": "5.55.1", + "@algolia/requester-fetch": "5.55.1", + "@algolia/requester-node-http": "5.55.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cva": { + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/cva/-/cva-1.0.0-beta.4.tgz", + "integrity": "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + }, + "peerDependencies": { + "typescript": ">= 4.5.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "license": "ISC" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fuse.js": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.4.2.tgz", + "integrity": "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/guess-json-indent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/guess-json-indent/-/guess-json-indent-3.0.1.tgz", + "integrity": "sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hookable": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", + "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/identifier-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.0.1.tgz", + "integrity": "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==", + "license": "MIT", + "dependencies": { + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-absolute-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-identifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.0.1.tgz", + "integrity": "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==", + "license": "MIT", + "dependencies": { + "identifier-regex": "^1.0.0", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/js-base64": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.8.0.tgz", + "integrity": "sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw==", + "license": "BSD-3-Clause" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-asynchronous/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/microdiff": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/microdiff/-/microdiff-1.5.0.tgz", + "integrity": "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q==", + "license": "MIT" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/neverpanic": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/neverpanic/-/neverpanic-0.0.8.tgz", + "integrity": "sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/preact": { + "version": "10.29.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", + "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix-vue": { + "version": "1.9.17", + "resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz", + "integrity": "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.7", + "@floating-ui/vue": "^1.1.0", + "@internationalized/date": "^3.5.4", + "@internationalized/number": "^3.5.3", + "@tanstack/vue-virtual": "^3.8.1", + "@vueuse/core": "^10.11.0", + "@vueuse/shared": "^10.11.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "fast-deep-equal": "^3.1.3", + "nanoid": "^5.0.7" + }, + "peerDependencies": { + "vue": ">= 3.2.0" + } + }, + "node_modules/radix-vue/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/radix-vue/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/radix-vue/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/radix-vue/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rehype-external-links": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-is-element": "^3.0.0", + "is-absolute-url": "^4.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-byte-length": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-byte-length/-/string-byte-length-3.0.1.tgz", + "integrity": "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/string-byte-slice": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-byte-slice/-/string-byte-slice-3.0.1.tgz", + "integrity": "sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg==", + "license": "MIT", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-object": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-6.0.0.tgz", + "integrity": "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-identifier": "^1.0.1", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/swrv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", + "integrity": "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==", + "license": "Apache-2.0", + "peerDependencies": { + "vue": ">=3.2.26 < 4" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT", + "peer": true + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/truncate-json": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/truncate-json/-/truncate-json-3.0.1.tgz", + "integrity": "sha512-QVsbr1WhGLq2F0oDyYbqtOXcf3gcnL8C9H5EX8bBwAr8ZWvWGJzukpPrDrWgJMrNtgDbo74BIjI4kJu3q2xQWw==", + "license": "MIT", + "dependencies": { + "guess-json-indent": "^3.0.1", + "string-byte-length": "^3.0.1", + "string-byte-slice": "^3.0.1" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unhead": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.15.tgz", + "integrity": "sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==", + "license": "MIT", + "dependencies": { + "hookable": "^6.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress/node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vitepress/node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/vitepress/node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vitepress/node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", + "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "license": "MIT" + }, + "node_modules/vue-sonner": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-1.3.2.tgz", + "integrity": "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A==", + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..1a16cf0 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,22 @@ +{ + "name": "taskpool-docs", + "version": "1.0.0", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.4" + }, + "dependencies": { + "@scalar/api-reference": "^1.48.2", + "dompurify": "^3.4.8", + "fast-uri": "^3.1.2", + "postcss": "^8.5.15" + }, + "overrides": { + "esbuild": "^0.25.0", + "vite": "^6.4.2" + } +} diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..e9dac84 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/example/builtin/test_env.js b/example/builtin/test_env.js new file mode 100644 index 0000000..02493e8 --- /dev/null +++ b/example/builtin/test_env.js @@ -0,0 +1,58 @@ +const taskpool = require('taskpool'); + +/** + * 内建工具环境变量管理测试示例 (Node.js) + * + * 使用说明: + * 1. 确保已在该 Node.js 环境下安装过内建包。 + * 2. 运行时系统需要注入有效的 OpenAPI 凭证 (BHPKG_OPENAPI_TOKEN 或 OPENAPI_TOKEN)。 + */ + +async function main() { + console.log("====== 开始运行 Node.js 环境变量管理示例 ======"); + + try { + // 获取全部环境变量 + const envs = await taskpool.getEnvs(); + console.log(`当前共有 ${envs.length} 个环境变量`); + + // 新增一个临时环境变量 + const newEnvName = "BHPKG_TEST_KEY_JS"; + const newEnvVal = "HelloTaskPoolJS"; + console.log(`正在创建环境变量: ${newEnvName}...`); + const createdEnv = await taskpool.addEnv( + newEnvName, + newEnvVal, + "Node.js SDK 测试自动创建" + ); + console.log(`创建成功: ID=${createdEnv.id}, Name=${createdEnv.name}`); + + // 查询该环境变量 + const checkedEnv = await taskpool.getEnv(newEnvName); + if (checkedEnv) { + console.log(`成功查询到变量: ${checkedEnv.name} = ${checkedEnv.value}`); + + // 修改该环境变量的值 + const updatedVal = "HelloTaskPoolJS_Updated"; + console.log(`正在修改环境变量的值为: ${updatedVal}...`); + const updatedEnv = await taskpool.updateEnv( + checkedEnv.id, + newEnvName, + updatedVal, + "Node.js SDK 测试自动更新" + ); + console.log(`更新成功: Value=${updatedEnv.value}`); + + // 删除该临时环境变量 + console.log(`正在删除临时环境变量: ID=${checkedEnv.id}...`); + await taskpool.deleteEnv(checkedEnv.id); + console.log("删除成功!"); + } + + } catch (e) { + console.error(`环境变量操作失败: ${e.message}`); + console.log("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。"); + } +} + +main(); diff --git a/example/builtin/test_env.py b/example/builtin/test_env.py new file mode 100644 index 0000000..d233e90 --- /dev/null +++ b/example/builtin/test_env.py @@ -0,0 +1,55 @@ +import taskpool + +# 内建工具环境变量管理测试示例 (Python) +# +# 前提条件: +# 1. 已经在环境中安装了 taskpool 包(例如通过 `taskpool builtininstall`) +# 2. 环境中已注入有效环境变量: +# - BHPKG_OPENAPI_TOKEN (必填,用于管理接口鉴权) + +def main(): + print("====== 开始运行 Python 环境变量管理示例 ======") + + try: + # 获取全部环境变量 + envs = taskpool.get_envs() + print(f"当前共有 {len(envs)} 个环境变量") + + # 新增一个临时环境变量 + new_env_name = "BHPKG_TEST_KEY" + new_env_val = "HelloTaskPool" + print(f"正在创建环境变量: {new_env_name}...") + created_env = taskpool.add_env( + name=new_env_name, + value=new_env_val, + remark="Python SDK 测试自动创建" + ) + print(f"创建成功: ID={created_env.get('id')}, Name={created_env.get('name')}") + + # 查询刚才创建的环境变量详情 + checked_env = taskpool.get_env(new_env_name) + if checked_env: + print(f"成功查询到变量: {checked_env.get('name')} = {checked_env.get('value')}") + + # 修改该环境变量的值 + updated_val = "HelloTaskPool_Updated" + print(f"正在修改环境变量的值为: {updated_val}...") + updated_env = taskpool.update_env( + id=checked_env.get("id"), + name=new_env_name, + value=updated_val, + remark="Python SDK 测试自动更新" + ) + print(f"更新成功: Value={updated_env.get('value')}") + + # 删除该临时环境变量 + print(f"正在删除临时环境变量: ID={checked_env.get('id')}...") + taskpool.delete_env(checked_env.get("id")) + print("删除成功!") + + except Exception as e: + print(f"环境变量操作失败: {e}") + print("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。") + +if __name__ == "__main__": + main() diff --git a/example/builtin/test_notify.js b/example/builtin/test_notify.js new file mode 100644 index 0000000..35463c8 --- /dev/null +++ b/example/builtin/test_notify.js @@ -0,0 +1,26 @@ +const taskpool = require('taskpool'); + +/** + * 内建通知测试示例 (Node.js) + * + * 使用说明: + * 1. 确保已在该 Node.js 环境下安装过内建包。 + * 2. 系统会自动注入 BHPKG_NOTIFY_TOKEN 和 BHPKG_NOTIFY_CHANNEL。 + */ + +console.log("正在尝试发送 Node.js 内建通知..."); + +try { + // 简单的一行代码即可完成推送 + taskpool.notify( + "Node.js 任务提醒", + "这是一条来自 Node.js 示例脚本的通知消息。无需配置 API 地址或 Token。" + ); + + console.log("发送请求已提交。"); + console.log("提示:内建包采用异步非阻塞发送,不会干扰主逻辑执行。"); + +} catch (e) { + console.error(`通知失败: ${e.message}`); + console.error("请检查环境变量是否注入,或是否已运行过 'taskpool builtininstall'。"); +} diff --git a/example/builtin/test_notify.py b/example/builtin/test_notify.py new file mode 100644 index 0000000..3781c06 --- /dev/null +++ b/example/builtin/test_notify.py @@ -0,0 +1,30 @@ +import taskpool + +# 内建通知测试示例 (Python) +# +# 前提条件: +# 1. 已经在环境中安装了 taskpool 包(例如通过 `taskpool builtininstall`) +# 2. 环境中已注入有效环境变量: +# - BHPKG_NOTIFY_TOKEN +# - BHPKG_NOTIFY_CHANNEL + +def main(): + print("正在尝试发送 Python 内建通知...") + try: + # 调用内建 notify 函数 + # 内部会自动使用 BHPKG_NOTIFY_TOKEN & BHPKG_NOTIFY_CHANNEL 进行鉴权和投递 + response = taskpool.notify( + title="Python 任务提醒", + text="这是一条来自 Python 示例脚本的通知消息。调用非常简单!" + ) + print("发送请求已处理。") + if response: + print(f"服务器响应: {response}") + + except ImportError as e: + print(f"错误: 库未正确加载。请确保已执行过环境初始化。详情: {e}") + except Exception as e: + print(f"发送过程发生异常: {e}") + +if __name__ == "__main__": + main() diff --git a/example/builtin/test_task.js b/example/builtin/test_task.js new file mode 100644 index 0000000..200a56f --- /dev/null +++ b/example/builtin/test_task.js @@ -0,0 +1,40 @@ +const taskpool = require('taskpool'); + +/** + * 内建工具任务管理测试示例 (Node.js) + * + * 使用说明: + * 1. 确保已在该 Node.js 环境下安装过内建包。 + * 2. 运行时系统需要注入有效的 OpenAPI 凭证 (BHPKG_OPENAPI_TOKEN 或 OPENAPI_TOKEN)。 + */ + +async function main() { + console.log("====== 开始运行 Node.js 任务管理与执行控制示例 ======"); + + try { + // 获取所有任务列表 + const tasks = await taskpool.getTasks(); + console.log(`成功获取到 ${tasks.length} 个定时任务:`); + tasks.slice(0, 5).forEach(task => { // 仅展示前5项 + console.log(` - [${task.id}] ${task.name} (表达式: ${task.schedule || ''}, 备注: ${task.remark || ''})`); + }); + + // 尝试触发第一个任务的运行 + if (tasks.length > 0) { + const targetTask = tasks[0]; + console.log(`\n尝试手动触发任务运行: [${targetTask.id}] ${targetTask.name}...`); + await taskpool.executeTask(targetTask.id); + console.log("执行指令发送成功。"); + } + + // 获取最近的执行结果列表 + const results = await taskpool.getLastResults(); + console.log(`\n最近共有 ${results.length} 条任务执行记录。`); + + } catch (e) { + console.error(`任务操作失败: ${e.message}`); + console.log("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。"); + } +} + +main(); diff --git a/example/builtin/test_task.py b/example/builtin/test_task.py new file mode 100644 index 0000000..150049a --- /dev/null +++ b/example/builtin/test_task.py @@ -0,0 +1,36 @@ +import taskpool + +# 内建工具任务管理与执行控制测试示例 (Python) +# +# 前提条件: +# 1. 已经在环境中安装了 taskpool 包(例如通过 `taskpool builtininstall`) +# 2. 环境中已注入有效环境变量: +# - BHPKG_OPENAPI_TOKEN (必填,用于管理接口鉴权) + +def main(): + print("====== 开始运行 Python 任务管理与执行控制示例 ======") + + try: + # 获取所有任务列表 + tasks = taskpool.get_tasks() + print(f"成功获取到 {len(tasks)} 个定时任务:") + for task in tasks[:5]: # 仅打印前5个 + print(f" - [{task.get('id')}] {task.get('name')} (表达式: {task.get('schedule')}, 备注: {task.get('remark')})") + + # 示例:尝试触发第一个任务的运行 + if tasks: + target_task = tasks[0] + print(f"\n尝试手动触发任务运行: [{target_task.get('id')}] {target_task.get('name')}...") + result = taskpool.execute_task(target_task.get("id")) + print("执行指令发送成功。") + + # 获取最近的执行结果列表 + results = taskpool.get_last_results() + print(f"\n最近共有 {len(results)} 条任务执行记录。") + + except Exception as e: + print(f"任务操作失败: {e}") + print("提示: 请确保在面板任务设置中正确注入了 OpenAPI Token。") + +if __name__ == "__main__": + main() diff --git a/example/deps/install_php_env_deps.sh b/example/deps/install_php_env_deps.sh new file mode 100644 index 0000000..5397604 --- /dev/null +++ b/example/deps/install_php_env_deps.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# ============================================================================== +# PHP 环境构建依赖安装脚本 +# ============================================================================== +# 说明: +# 如果你需要使用 PHP 环境,由于 mise 安装某些 PHP 版本时需要从源码编译, +# 建议在面板的“任务训练”中创建一个“开机任务”,并关联此脚本, +# 以确保容器重启后依然具备编译 PHP 所需的系统依赖。 +# ============================================================================== + +set -e + +echo "正在检测 PHP 编译依赖..." + +# Debian 系统依赖包列表 +PACKAGES="autoconf bison build-essential libssl-dev libxml2-dev pkg-config libsqlite3-dev zlib1g-dev libcurl4-openssl-dev libpng-dev libjpeg-dev libonig-dev libzip-dev" +CHECK_CMD="dpkg -l" +INSTALL_CMD="apt-get install -y" +UPDATE_CMD="apt-get update" + +MISSING_PACKAGES="" +for pkg in $PACKAGES; do + if ! $CHECK_CMD "$pkg" >/dev/null 2>&1; then + MISSING_PACKAGES="$MISSING_PACKAGES $pkg" + fi +done + +if [ -z "$MISSING_PACKAGES" ]; then + echo "所有依赖已就绪,跳过安装。" + exit 0 +fi + +echo "检测到缺失依赖: $MISSING_PACKAGES" + +# 执行安装 +if [ -n "$UPDATE_CMD" ]; then + $UPDATE_CMD +fi + +$INSTALL_CMD $MISSING_PACKAGES + +echo "PHP 编译依赖安装完成!" diff --git a/example/playwright/playwright.js b/example/playwright/playwright.js new file mode 100644 index 0000000..c5cffde --- /dev/null +++ b/example/playwright/playwright.js @@ -0,0 +1,32 @@ + + +/* +下面是测试实例 +*/ + +const puppeteer = require('puppeteer-core'); + +(async () => { + // 连接 Browserless(注意 token) + const browser = await puppeteer.connect({ + browserWSEndpoint: 'ws://localhost:3000?token=your-secret-token' + }); + + const page = await browser.newPage(); + + // 访问百度 + await page.goto('https://www.baidu.com', { + waitUntil: 'networkidle2', + timeout: 30000 + }); + + // 截图 + await page.screenshot({ + path: 'baidu.png', + fullPage: true + }); + + console.log('✅ 截图完成:baidu.png'); + + await browser.close(); +})(); diff --git a/example/playwright/playwright.py b/example/playwright/playwright.py new file mode 100644 index 0000000..775dab8 --- /dev/null +++ b/example/playwright/playwright.py @@ -0,0 +1,25 @@ +""" +Python 示例:通过 Playwright 连接 Browserless,并抓取页面截图。 +运行前请先在 TaskPool 的“语言依赖”中为 Python 安装 playwright 包。 +如果使用 Browserless,请不要执行 `playwright install`。 +""" + +from playwright.sync_api import sync_playwright + + +def main() -> None: + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp( + "http://browser:3000?token=your-secret-token" + ) + + page = browser.new_page() + page.goto("https://www.baidu.com", wait_until="networkidle", timeout=30000) + page.screenshot(path="baidu.png", full_page=True) + + print("截图已保存为 baidu.png") + browser.close() + + +if __name__ == "__main__": + main() diff --git a/example/playwright/readme.md b/example/playwright/readme.md new file mode 100644 index 0000000..05f9961 --- /dev/null +++ b/example/playwright/readme.md @@ -0,0 +1,170 @@ +# Playwright 示例 + +这组示例用于演示如何在 TaskPool 中通过远程 Browserless / Chromium 服务执行浏览器自动化,并完成一次最小可用的页面截图。 + +当前目录包含两个脚本: + +```text +example/ +└── playwright/ + ├── playwright.js + ├── playwright.py + └── readme.md +``` + +- `playwright.js`:Node.js 示例,使用 `puppeteer-core` 连接 Browserless +- `playwright.py`:Python 示例,使用 `playwright` 连接 Browserless + +其中 Python 方案是最主流、也最接近 Puppeteer 使用体验的一种。 +同时建议在 TaskPool 中为 Python 示例安装并使用 Python `3.11` 版本,因为过高版本的 Python 可能会出现依赖安装失败。 + +## 先安装语言依赖 + +运行脚本前,请先到 TaskPool 的“语言依赖”页面安装对应的包,否则任务会因为缺少模块而失败。 + +- 运行 `playwright.js` 前,请在 Node.js 语言依赖中安装:`puppeteer-core` +- 运行 `playwright.py` 前,请在 Python 语言依赖中安装:`playwright` +- Python 运行环境建议选择:`3.11`,避免因为版本过高导致依赖安装失败 + +安装完成后,再创建任务执行对应脚本。 + +如果你当前是通过 Browserless 连接远程浏览器: + +- 不要执行 `playwright install` +- 不需要额外下载 Chromium / Firefox / WebKit +- 直接使用 `connect_over_cdp` 连接远程浏览器即可 + +## 适用场景 + +- 验证 TaskPool 到 Browserless 的网络连通性 +- 验证远程浏览器地址和 Token 是否配置正确 +- 快速测试浏览器自动化脚本是否能正常运行 + +## 推荐部署方式 + +建议配合 `browserless/chromium` 一起使用,再由 TaskPool 中的脚本连接远程浏览器服务。 + +参考 `docker-compose.yml`: + +```yaml +version: "3.8" + +services: + browser: + image: ghcr.io/browserless/chromium:latest + container_name: browser + restart: unless-stopped + environment: + MAX_CONCURRENT_SESSIONS: 5 + MAX_QUEUE_LENGTH: 20 + CONNECTION_TIMEOUT: 300000 + DEFAULT_LAUNCH_ARGS: '["--no-sandbox","--disable-setuid-sandbox","--disable-dev-shm-usage"]' + TOKEN: your-secret-token + ENABLE_DEBUGGER: "false" + shm_size: "1gb" + mem_limit: 2g + cpus: 2 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/healthz"] + interval: 30s + timeout: 10s + retries: 3 + + taskpool: + image: ghcr.io/engigu/taskpool:latest + container_name: taskpool + restart: unless-stopped + ports: + - "8052:8052" + volumes: + - ./data:/app/data + - ./envs:/app/envs + environment: + - TZ=Asia/Shanghai + - BH_SERVER_PORT=8052 + - BH_SERVER_HOST=0.0.0.0 + - BH_DB_TYPE=sqlite + - BH_DB_PATH=/app/data/taskpool.db + - BH_DB_TABLE_PREFIX=taskpool_ + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + depends_on: + - browser +``` + +## 使用步骤 + +1. 启动 `browser` 和 `taskpool` 服务。 +2. 在 TaskPool 的“语言依赖”中安装对应包。 +3. 按实际环境修改脚本中的 Browserless 地址和 Token。 +4. 在 TaskPool 中创建任务并运行对应脚本。 + +Python 示例的核心写法如下: + +```python +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.connect_over_cdp( + "http://browser:3000?token=your-secret-token" + ) + page = browser.new_page() + page.goto("https://www.baidu.com") + page.screenshot(path="baidu.png") + browser.close() +``` + +## 运行前检查 + +- Browserless 服务已经正常启动 +- TaskPool 可以访问 Browserless 地址 +- `TOKEN` 与 Browserless 配置保持一致 +- 脚本中的地址、端口和协议填写正确 + +## 常见问题 + +### 1. 报错提示找不到模块 + +通常是还没有在“语言依赖”中安装对应包: + +- Node.js 示例需要 `puppeteer-core` +- Python 示例需要 `playwright` + +### 2. 为什么没有执行 `playwright install` + +这是预期行为。 + +如果你使用的是 Browserless 这类远程浏览器服务,Playwright 只是作为客户端发起连接,不需要在任务池容器里再下载本地浏览器,所以通常不要执行 `playwright install`。 + +### 3. 连接不上 Browserless + +请优先检查: + +- Browserless 服务是否正常启动 +- Token 是否正确 +- TaskPool 与 Browserless 是否在同一网络中 +- 地址是否写成了当前运行环境可访问的地址 + +### 4. 页面打开超时 + +可以尝试: + +- 换一个更稳定的目标站点 +- 调大超时时间 +- 增加 Browserless 容器的 `shm_size` +- 检查容器 CPU / 内存是否不足 + +### 5. 没有看到截图文件 + +请确认: + +- 脚本已经执行成功 +- 截图保存路径是否正确 +- 任务工作目录是否符合预期 + +## 说明 + +这组示例主要用于快速验证 TaskPool 与远程浏览器服务之间的连通性,以及对应语言环境是否已经配置完成。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79d5b7e --- /dev/null +++ b/go.mod @@ -0,0 +1,141 @@ +module github.com/engigu/taskpool + +go 1.26.0 + +require ( + github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1 + github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3 + github.com/alibabacloud-go/tea v1.5.0 + github.com/creack/pty v1.1.24 + github.com/gin-contrib/pprof v1.5.4 + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/gofrs/flock v0.13.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/yamux v0.1.2 + github.com/robfig/cron/v3 v3.0.1 + github.com/rs/xid v1.6.0 + github.com/shirou/gopsutil/v3 v3.24.5 + github.com/silenceper/wechat/v2 v2.1.13 + github.com/sirupsen/logrus v1.9.4 + go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 + golang.org/x/text v0.38.0 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + gopkg.in/ini.v1 v1.67.3 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 // indirect + github.com/air-verse/air v1.64.5 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/endpoint-util v1.1.1 // indirect + github.com/alibabacloud-go/openapi-util v0.1.2 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect + github.com/aliyun/credentials-go v1.4.12 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bep/godartsass/v2 v2.5.0 // indirect + github.com/bep/golibsass v1.2.0 // indirect + github.com/bits-and-blooms/bitset v1.24.5 // indirect + github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dlclark/regexp2/v2 v2.2.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/evanw/esbuild v0.28.1 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/getkin/kin-openapi v0.140.0 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect + github.com/gohugoio/hugo v0.163.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hairyhenderson/go-codeowners v0.7.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/olekukonko/errors v1.3.0 // indirect + github.com/olekukonko/ll v0.1.8 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.4.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shoenig/go-m1cpu v0.2.1 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/tdewolff/parse/v2 v2.8.13 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/arch v0.28.0 // indirect + golang.org/x/image v0.43.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/tools v0.46.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + modernc.org/libc v1.73.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect +) + +tool github.com/air-verse/air diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63d5655 --- /dev/null +++ b/go.sum @@ -0,0 +1,714 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= +github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= +github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4= +github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.11/go.mod h1:wHxkgZT1ClZdcwEVP/pDgYK/9HucsnCfMipmJgCz4xY= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1 h1:R8b55YFS4K9x5P5IdgA+QWinVfVmulzqaJG/tr6HhxM= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1/go.mod h1:OCFim1kMbp2m+V8WS5IBnnVrk6nXaJiDwZpg3uqw8Po= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3 h1:32N2pGk28weVZ5/rjNk9gPx/jrRkR0rX9i8Id6IlyUY= +github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3/go.mod h1:gPbHx4BTxLIDNRfYNGGmp6aIpeNBamtdDlPcK4UTUto= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/endpoint-util v1.1.1 h1:ZkBv2/jnghxtU0p+upSU0GGzW1VL9GQdZO3mcSUTUy8= +github.com/alibabacloud-go/endpoint-util v1.1.1/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= +github.com/alibabacloud-go/openapi-util v0.1.2 h1:aljdyAPotH4xHymo5wzARjcHb3Org0zKnLP4RxS0JGY= +github.com/alibabacloud-go/openapi-util v0.1.2/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea v1.5.0 h1:8pUo8WzMChtJT+jpLIcN1vzMi6SW3rihzzBoihPGUvs= +github.com/alibabacloud-go/tea v1.5.0/go.mod h1:hgSs82CkOiehSQMoiFN79dL6zsGX7pVGvnn9SIEs8/0= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA= +github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M= +github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/aliyun/credentials-go v1.4.12 h1:7D8eXGotNwthZuUEgAMgBoqxmIHwfaPVwW+/04LIJSQ= +github.com/aliyun/credentials-go v1.4.12/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= +github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc= +github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo= +github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA= +github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c= +github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg= +github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= +github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI= +github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= +github.com/bep/golocales v0.2.0 h1:4H1H5UPw3ainpj5zykeEfiMRQngyaIC/t+I4Dvn+fvE= +github.com/bep/golocales v0.2.0/go.mod h1:Hl78nje8mNL3LzLeJvYN9NsIZgyFJGrGfvgO9r1+mwE= +github.com/bep/goportabletext v0.2.0 h1:CZ9f8jADBWqHwBymQiJJPCTSV/tHSA+PYzlUf86Yze0= +github.com/bep/goportabletext v0.2.0/go.mod h1:xDeA5+qcgKzJq6Q6XjAiBKtxLD3Yn7f6XP4joD3J3qU= +github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY= +github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps= +github.com/bep/imagemeta v0.17.2 h1:fDyXM1eAqCfBeqGLqS6UsN4OfuLM0cdu70KuLCehjOg= +github.com/bep/imagemeta v0.17.2/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA= +github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg= +github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w= +github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ= +github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0= +github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs= +github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc= +github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po= +github.com/bep/textandbinarywriter v0.1.0/go.mod h1:dAcHveajlWWU7PXhp6Dn4PIAYDg2H13Huif9xMS2w8w= +github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s= +github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0= +github.com/bits-and-blooms/bitset v1.24.5 h1:654xBVHc23gJMAgOTkPNoCVfiRxuIOAUnAZFtopqJ4w= +github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8= +github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0= +github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc= +github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= +github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= +github.com/gin-contrib/pprof v1.5.4 h1:daxf2UNZw5IEx6WBdcpnEzgeDfkp083KdjUAFuMEkK4= +github.com/gin-contrib/pprof v1.5.4/go.mod h1:AUxwt9kgpJGWiytj6RorBqRMyFuOQk7dPNfM9nHEY9I= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc= +github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4= +github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I= +github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6/go.mod h1:m5hu1im5Qc7LDycVLvee6MPobJiRLBYHklypFJR0/aE= +github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yrhg= +github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU= +github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI= +github.com/gohugoio/hugo v0.163.3 h1:fkiOvLv8hYZt96OhJE9RDWw4IkQVASCIIndBmAiLHgM= +github.com/gohugoio/hugo v0.163.3/go.mod h1:3FPGFgeqGYDTPRe1NJ10L1giY5ViUGeNlkVBDmVk46w= +github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus= +github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs= +github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI= +github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hairyhenderson/go-codeowners v0.7.1 h1:4yKuRk01b7DC+pnGMzsod4njH05EXsBjFlZcFmTn19g= +github.com/hairyhenderson/go-codeowners v0.7.1/go.mod h1:oxxEFdmWj8geqXtG7tNO/rXF/hV7got2sSPdaIKpGVI= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= +github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= +github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= +github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= +github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= +github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= +github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= +github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= +github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row= +github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= +github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/silenceper/wechat/v2 v2.1.13 h1:4JCnQKFtw7t5DNTZPa/ITralevehQnl6BapjBp3W3Ns= +github.com/silenceper/wechat/v2 v2.1.13/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= +github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= +github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick= +github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= +github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= +github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= +golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ= +modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..adbe355 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,169 @@ +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/router" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/tunnel" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type App struct { + Config *services.AppConfig + Router *gin.Engine +} + +func New() *App { + app := InitBasic() + app.initRouter() + + // 初始化完成后将路由引擎注入到隧道模块,以支持高性能的纯内存代理 + tunnel.SetLocalEngine(app.Router) + + // 初始化隧道后台服务 (读取配置决定角色并启动服务) + tunnel.Init() + + // 启动系统级后台定时任务调度器 + executor.InitSysCron() + + // 初始化完成后回收一次内存 + utils.FreeMemory() + + return app +} + +var ( + globalApp *App + initOnce sync.Once +) + +func InitBasic() *App { + initOnce.Do(func() { + app := &App{} + utils.InitRuntime() + utils.InitSecretKey() + + // 自动加载配置 (内部会自动处理 BH_CONFIG_PATH 环境变量与默认路径的优先级) + app.initConfigWithPath("") + app.initDatabase() + + logger.Infof("[System] 低于1.0.11版本升级最新版本错误指引: https://github.com/engigu/taskpool/issues/64") + globalApp = app + }) + return globalApp +} + +// InitBasicForCmd 专为命令行工具定制的基础环境初始化入口 +// 内部会调高控制台日志过滤级别以自动静默屏蔽刷屏的底层系统与组件启动 Info 日志 +func InitBasicForCmd() *App { + logger.SetLevel("warn") + return InitBasic() +} + +// func (a *App) initConfig() { +// a.initConfigWithPath(constant.ConfigPath) +// } + +func (a *App) initConfigWithPath(path string) { + cfg, err := services.LoadConfig(path) + if err != nil { + logger.Fatalf("Failed to load config: %v", err) + } + a.Config = cfg + + // Ensure directories exist + err = os.MkdirAll(constant.DataDir, 0755) + if err != nil { + return + } + err = os.MkdirAll(constant.ScriptsWorkDir, 0755) + if err != nil { + return + } + + a.setupTaskPoolBin() +} + +func (a *App) setupTaskPoolBin() { + binDir := filepath.Join(constant.DataDir, "bin") + _ = os.MkdirAll(binDir, 0755) + + exe, err := os.Executable() + if err == nil { + // 新命令名 + linkPath := filepath.Join(binDir, "taskpool") + if runtime.GOOS == "windows" { + linkPath += ".exe" + } + os.Remove(linkPath) + _ = os.Symlink(exe, linkPath) + + // 兼容旧命令名 baihu + legacyPath := filepath.Join(binDir, "baihu") + if runtime.GOOS == "windows" { + legacyPath += ".exe" + } + os.Remove(legacyPath) + _ = os.Symlink(exe, legacyPath) + } +} + +func (a *App) initDatabase() { + dbCfg := &database.Config{ + Type: a.Config.Database.Type, + Host: a.Config.Database.Host, + Port: a.Config.Database.Port, + User: a.Config.Database.User, + Password: a.Config.Database.Password, + DBName: a.Config.Database.DBName, + Path: a.Config.Database.Path, + DSN: a.Config.Database.DSN, + SSLMode: a.Config.Database.SSLMode, + } + + if err := database.Init(dbCfg); err != nil { + logger.Fatalf("Failed to init database: %v", err) + } + + // 记录各个初始化阶段的时间 + startTime := time.Now() + + // 执行 V3 迁移(ID 变更迁移) + if err := services.RunMigrationV3(); err != nil { + logger.Fatalf("Failed to run V3 migration: %v", err) + } + v3Duration := time.Since(startTime) + logger.Infof("[Database] V3 迁移检查完成, 耗时: %v", v3Duration) + + // 执行表结构同步 + migrateStart := time.Now() + if err := database.Migrate(); err != nil { + logger.Fatalf("Failed to migrate database: %v", err) + } + migrateDuration := time.Since(migrateStart) + logger.Infof("[Database] 表结构同步完成, 耗时: %v", migrateDuration) + logger.Infof("[Database] 数据库总初始化耗时: %v", time.Since(startTime)) +} + +func (a *App) initRouter() { + ctrls := router.RegisterControllers() + a.Router = router.Setup(ctrls) +} + +func (a *App) Run() { + addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port) + logger.Infof("Starting server on %s", addr) + a.Router.Run(addr) +} diff --git a/internal/bootstrap/client.go b/internal/bootstrap/client.go new file mode 100644 index 0000000..b656651 --- /dev/null +++ b/internal/bootstrap/client.go @@ -0,0 +1,58 @@ +package bootstrap + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/engigu/taskpool/internal/services" +) + +// SendInternalRequest 向常驻后台主服务安全发送内部通信请求 +// relPath 传入相对内部接口路径 (如: "/internal/tasks/execute/xxx"),方法内部会自动补充完整的协议、端口及 "/api/v1" 前缀, +// 并自动获取 security.secret 密钥种入 X-Internal-Token 头部。 +func SendInternalRequest(method, relPath string, payload interface{}) ([]byte, int, error) { + appCfg := services.GetConfig() + if appCfg == nil { + return nil, 0, fmt.Errorf("加载系统配置失败") + } + + relPath = strings.TrimPrefix(relPath, "/") + url := fmt.Sprintf("http://127.0.0.1:%d/api/v1/%s", appCfg.Server.Port, relPath) + + var bodyReader io.Reader + if payload != nil { + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, 0, fmt.Errorf("序列化请求负载失败: %v", err) + } + bodyReader = bytes.NewBuffer(jsonData) + } + + settings := services.NewSettingsService() + secret := settings.Get("security", "secret") + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("创建 HTTP 请求失败: %v", err) + } + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("X-Internal-Token", secret) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("网络连接失败,请确保任务池常驻后台服务正在运行中: %v", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + return bodyBytes, resp.StatusCode, err +} diff --git a/internal/cache/site_cache.go b/internal/cache/site_cache.go new file mode 100644 index 0000000..5cd60f6 --- /dev/null +++ b/internal/cache/site_cache.go @@ -0,0 +1,94 @@ +package cache + +import ( + "sync" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" +) + +// siteCache 站点设置内存缓存 +var ( + siteCache = make(map[string]string) + siteCacheMu sync.RWMutex + siteCacheInit bool +) + +// LoadSiteCache 从数据库加载站点设置到缓存 +func LoadSiteCache() { + siteCacheMu.Lock() + defer siteCacheMu.Unlock() + + // 先填充默认值 + if defaults, ok := constant.DefaultSettings[constant.SectionSite]; ok { + for k, v := range defaults { + siteCache[k] = v + } + } + + // 从数据库加载覆盖 + var settings []models.Setting + database.DB.Where("section = ?", constant.SectionSite).Find(&settings) + for _, setting := range settings { + siteCache[setting.Key] = string(setting.Value) + } + siteCacheInit = true +} + +// ensureSiteCache 确保站点缓存已初始化 +func ensureSiteCache() { + siteCacheMu.RLock() + init := siteCacheInit + siteCacheMu.RUnlock() + + if !init { + LoadSiteCache() + } +} + +// GetSiteCache 从缓存获取站点设置 +func GetSiteCache(key string) string { + ensureSiteCache() + + siteCacheMu.RLock() + defer siteCacheMu.RUnlock() + + if val, ok := siteCache[key]; ok { + return val + } + if def, ok := constant.DefaultSettings[constant.SectionSite][key]; ok { + return def + } + return "" +} + +// SetSiteCache 更新缓存中的站点设置 +func SetSiteCache(key, value string) { + siteCacheMu.Lock() + siteCache[key] = value + siteCacheMu.Unlock() +} + +// GetSiteCacheAll 获取整个站点设置缓存 +func GetSiteCacheAll() map[string]string { + ensureSiteCache() + + siteCacheMu.RLock() + defer siteCacheMu.RUnlock() + + result := make(map[string]string) + for k, v := range siteCache { + result[k] = v + } + return result +} + +// SetSiteCacheBatch 批量更新缓存 +func SetSiteCacheBatch(values map[string]string) { + siteCacheMu.Lock() + for k, v := range values { + siteCache[k] = v + } + siteCacheMu.Unlock() +} diff --git a/internal/constant/commands.go b/internal/constant/commands.go new file mode 100644 index 0000000..2c9eac5 --- /dev/null +++ b/internal/constant/commands.go @@ -0,0 +1,43 @@ +package constant + +// CommandInfo 定义了终端可用命令的说明信息 +type CommandInfo struct { + Name string + Description string +} + +// Commands 是系统的可用业务命令说明列表 +var Commands = []CommandInfo{ + // { + // Name: "server", + // Description: "启动后台服务进程", + // }, + { + Name: "reposync", + Description: "同步远程 Git 仓库或文件到本地", + }, + { + Name: "resetpwd", + Description: "重置 admin 用户密码(需要二次确认)", + }, + { + Name: "restore", + Description: "从本地 zip 文件中全量恢复系统级备份数据", + }, + { + Name: "builtininstall", + Description: "为所有 mise 管理的 Node.js 和 Python 环境安装内建助手库", + }, + { + Name: "task", + Description: "系统级任务的列表查询、触发运行、启停控制及状态查看", + }, + { + Name: "depinstall", + Description: "一键补全指定任务执行日志中的缺失依赖包", + }, + { + Name: "version", + Description: "查看当前系统版本号 (同 -v, -V)", + }, +} diff --git a/internal/constant/constant.go b/internal/constant/constant.go new file mode 100644 index 0000000..c76e661 --- /dev/null +++ b/internal/constant/constant.go @@ -0,0 +1,241 @@ +package constant + +import "time" + +const ( + + // DefaultRole 默认用户角色 + DefaultRole = "user" + + // AdminRole 管理员角色 + AdminRole = "admin" + + // DefaultTaskTimeout 默认任务超时时间(分钟) + DefaultTaskTimeout = 30 + + // Settings Section 常量 + SectionSite = "site" + SectionSystem = "system" + SectionScheduler = "scheduler" + SectionSecurity = "security" + SectionNotify = "notify" + + // Site Settings Key 常量 + KeyTitle = "title" + KeySubtitle = "subtitle" + KeyIcon = "icon" + KeyPageSize = "page_size" + KeyCookieDays = "cookie_days" + KeyOpenapiToken = "openapi_token" + KeyActiveWebUI = "active_webui" + + // Security Settings Key 常量 + KeySecret = "secret" + + // System Settings Key 常量 + KeyInitialized = "initialized" + // KeyLogRetention = "log_retention" // Deprecated + + // Log Retention Keys + KeySystemNoticeDays = "system_notice_days" + KeySystemNoticeMaxCount = "system_notice_max_count" + KeyPushLogDays = "push_log_days" + KeyPushLogMaxCount = "push_log_max_count" + KeyLoginLogDays = "login_log_days" + KeyLoginLogMaxCount = "login_log_max_count" + KeySchedulerLogDays = "scheduler_log_days" + KeySchedulerLogMaxCount = "scheduler_log_max_count" + + // Scheduler Settings Key 常量 + KeyWorkerCount = "worker_count" + KeyQueueSize = "queue_size" + KeyRateInterval = "rate_interval" + + // Notify Settings Key 常量 + KeyNotifyChannels = "channels" + KeyNotifyEvents = "events" + KeyNotifyToken = "notify_token" + KeyNotifyPrefix = "notify_prefix" + + // Notify Templates Keys + KeyNotifyTemplateUserLoginTitle = "notify_template_user_login_title" + KeyNotifyTemplateUserLoginText = "notify_template_user_login_text" + KeyNotifyTemplateBruteForceLoginTitle = "notify_template_brute_force_login_title" + KeyNotifyTemplateBruteForceLoginText = "notify_template_brute_force_login_text" + KeyNotifyTemplatePasswordChangedTitle = "notify_template_password_changed_title" + KeyNotifyTemplatePasswordChangedText = "notify_template_password_changed_text" + KeyNotifyTemplateTaskSuccessTitle = "notify_template_task_success_title" + KeyNotifyTemplateTaskSuccessText = "notify_template_task_success_text" + KeyNotifyTemplateTaskFailedTitle = "notify_template_task_failed_title" + KeyNotifyTemplateTaskFailedText = "notify_template_task_failed_text" + KeyNotifyTemplateTaskTimeoutTitle = "notify_template_task_timeout_title" + KeyNotifyTemplateTaskTimeoutText = "notify_template_task_timeout_text" + + // 事件绑定类型 + BindingTypeSystem = "system" + BindingTypeTask = "task" + + // 系统事件类型 + EventUserLogin = "user_login" + EventBruteForceLogin = "brute_force_login" + EventPasswordChanged = "password_changed" + + // 任务事件类型 + EventTaskSuccess = "task_success" + EventTaskFailed = "task_failed" + EventTaskTimeout = "task_timeout" + EventTaskRunning = "task_running" + EventTaskQueued = "task_queued" + EventTaskCancelled = "task_cancelled" + + // 其他事件类型 + EventSystemNotice = "system_notice" + EventSchedulerLog = "scheduler_log" + EventNotifySent = "notify_sent" + EventAppLogAdded = "app_log_added" + + // WebSocket 消息类型 + WSTypeHeartbeat = "heartbeat" + WSTypeHeartbeatAck = "heartbeat_ack" + WSTypeTasks = "tasks" + WSTypeTaskResult = "task_result" + WSTypeTaskLog = "task_log" + WSTypeExecute = "execute" + WSTypeUpdate = "update" + WSTypeDisconnect = "disconnect" + WSTypeConnected = "connected" + WSTypeDisabled = "disabled" + WSTypeEnabled = "enabled" + WSTypeFetchTasks = "fetch_tasks" + WSTypeTaskHeartbeat = "task_heartbeat" + WSTypeStop = "stop" + + // 任务状态 + TaskStatusSuccess = "success" + TaskStatusFailed = "failed" + TaskStatusRunning = "running" + TaskStatusPending = "pending" + TaskStatusTimeout = "timeout" + TaskStatusCancelled = "cancelled" + TaskStatusQueued = "queued" + + // 任务类型 + TaskTypeNormal = "task" + TaskTypeRepo = "repo" + + // 任务置顶类型 + PinTypeNone = "none" + PinTypeTop = "top" + + // 触发类型(新值 taskpool_startup;读取时兼容旧值 baihu_startup) + TriggerTypeCron = "cron" + TriggerTypeTaskPoolStartup = "taskpool_startup" + + // Agent 状态 + AgentStatusOnline = "online" + AgentStatusOffline = "offline" + + // AppLog 分类 + LogCategoryDefault = "default" + LogCategorySystemNotice = "system_notice" + LogCategoryPushLog = "push_log" + LogCategoryLoginLog = "login_log" + LogCategorySchedulerLog = "scheduler_log" + + // AppLog 级别 + LogLevelInfo = "info" + LogLevelWarning = "warning" + LogLevelError = "error" + + // AppLog 状态 + LogStatusUnread = "unread" + LogStatusRead = "read" + LogStatusSuccess = "success" + LogStatusFailed = "failed" + + // Env Type + EnvTypeNormal = "normal" + EnvTypeSecret = "secret" + + // Relation Types + RelationTypeTaskTag = "task_tag" + RelationTypeTaskEnv = "task_env" + RelationTypeEnvTag = "env_tag" + + // WebSocket 安全常量 + // PongWait 收到 pong 的超时时间 + PongWait = 60 * time.Second + // PingPeriod 发送 ping 的周期 + PingPeriod = (PongWait * 9) / 10 + // MaxMessageSize 允许的最大消息大小 + MaxMessageSize = 1024 * 1024 // 1MB + // MaxLogSize 允许的最大日志大小 (保留末尾 10MB) + MaxLogSize = 10 * 1024 * 1024 // 10MB + + // ScriptsDirPlaceholder 脚本目录占位符 + ScriptsDirPlaceholder = "$SCRIPTS_DIR$" +) + +// CookieName Cookie 名称 +var CookieName = "BHToken" + +// TablePrefix 表前缀,从配置文件读取 +var TablePrefix string + +// Runtime 数据库配置快照,用于需要单独启动内部子进程(如 reposync)时显式透传数据库连接信息, +// 避免主进程启动阶段清理环境变量后,子进程意外回退到默认 sqlite 配置。 +var ( + RuntimeDBType string + RuntimeDBHost string + RuntimeDBPort int + RuntimeDBUser string + RuntimeDBPassword string + RuntimeDBName string + RuntimeDBPath string + RuntimeDBDSN string + RuntimeDBTablePrefix string + RuntimeDBSSLMode string +) + +// Secret JWT和密码salt密钥,运行中自动从数据库加载 +var Secret string + +// DemoMode 演示模式,从环境变量读取 +var DemoMode bool + +// DefaultIcon 默认站点图标 +var DefaultIcon = `` + +// DefaultSettings 默认系统设置 +var DefaultSettings = map[string]map[string]string{ + SectionSite: { + KeyTitle: "任务池", + KeySubtitle: "极致轻量、高性能的自动化任务调度平台", + KeyIcon: DefaultIcon, + KeyPageSize: "10", + KeyCookieDays: "7", + KeyActiveWebUI: "default", + }, + SectionScheduler: { + KeyWorkerCount: "4", + KeyQueueSize: "100", + KeyRateInterval: "200", + }, + SectionNotify: { + KeyNotifyPrefix: "[任务池]", + // Login + KeyNotifyTemplateUserLoginTitle: "用户登录(成功/失败)", + KeyNotifyTemplateUserLoginText: "用户 {{username}} 在 IP {{ip}} 登录{{status_label}}\n{{message}}", + KeyNotifyTemplateBruteForceLoginTitle: "系统安全警告", + KeyNotifyTemplateBruteForceLoginText: "检测到 IP {{ip}} 正在尝试暴力破解用户 {{username}}", + KeyNotifyTemplatePasswordChangedTitle: "账户安全通知", + KeyNotifyTemplatePasswordChangedText: "用户 {{username}} 刚刚修改了密码", + // Task + KeyNotifyTemplateTaskSuccessTitle: "任务[{{task_name}}] 成功", + KeyNotifyTemplateTaskSuccessText: "任务 #{{task_id}} {{task_name}}\n状态: 成功\n耗时: {{duration}}ms\n执行结果: {{output}}", + KeyNotifyTemplateTaskFailedTitle: "任务[{{task_name}}] 失败", + KeyNotifyTemplateTaskFailedText: "任务 #{{task_id}} {{task_name}}\n状态: 失败\n执行时间: {{start_time}}\n原因: {{error}}\n最后输出: {{output}}", + KeyNotifyTemplateTaskTimeoutTitle: "任务[{{task_name}}] 超时", + KeyNotifyTemplateTaskTimeoutText: "任务 #{{task_id}} {{task_name}}\n状态: 超时\n耗时: {{duration}}ms\n最后输出: {{output}}", + }, +} diff --git a/internal/constant/interconnect.go b/internal/constant/interconnect.go new file mode 100644 index 0000000..b5ce1b2 --- /dev/null +++ b/internal/constant/interconnect.go @@ -0,0 +1,22 @@ +package constant + +const ( + // CookieActiveInterconnectNodeID 穿越状态下标识目标子节点 ID 的 Cookie 键名 + CookieActiveInterconnectNodeID = "active_interconnect_node_id" + + // SectionInterconnect 互联设置分组 + SectionInterconnect = "interconnect" + + // 互联设置相关 Key + KeyInterconnectToken = "interconnect_token" + KeyInterconnectParentURL = "interconnect_parent_url" + KeyInterconnectParentToken = "interconnect_parent_token" + KeyInterconnectRole = "interconnect_role" + + // 互联角色 + InterconnectRoleMaster = "master" + InterconnectRoleChild = "child" + + // 互联系统事件 + EventInterconnectChildStatus = "interconnect_child_status" +) diff --git a/internal/constant/migrate.go b/internal/constant/migrate.go new file mode 100644 index 0000000..0b46d1a --- /dev/null +++ b/internal/constant/migrate.go @@ -0,0 +1,21 @@ +package constant + +const ( + // IDSchemaSignature 数据库表结构指纹的固定 ID + IDSchemaSignature = "sys_schema_sig" + + // KeySchemaSignature 数据库表结构指纹,用于判断是否需要全量自动建表 + KeySchemaSignature = "schema_signature" + + // KeyTaskEnvsMigrated 任务环境变量迁移标记 (v2版本强制重跑过) + KeyTaskEnvsMigrated = "task_envs_migrated_v2" + + // KeyTaskTagsMigrated 任务标签迁移标记 (v2版本强制重跑过) + KeyTaskTagsMigrated = "task_tags_migrated_v2" + + // 以下是旧表或字段名,用于前置结构迁移时重命名或删除 + TableMigrateQlTokens = "ql_tokens" + ColumnMigrateQlTokenCode = "code" + ColumnMigrateQlTokenToken = "token" + ColumnMigrateDependencyType = "type" +) diff --git a/internal/constant/mise.go b/internal/constant/mise.go new file mode 100644 index 0000000..dba8297 --- /dev/null +++ b/internal/constant/mise.go @@ -0,0 +1,9 @@ +package constant + +// MainstreamMisePlugins 主流的 mise 插件列表 +var MainstreamMisePlugins = []string{ + "python", "node", "go", "rust", "ruby", "java", "php", + "deno", "bun", "zig", "dotnet", "elixir", "erlang", + "crystal", "lua", "julia", "nim", "perl", "scala", + "kotlin", "clojure", "dart", "flutter", "terraform", +} diff --git a/internal/constant/path.go b/internal/constant/path.go new file mode 100644 index 0000000..7eb69fb --- /dev/null +++ b/internal/constant/path.go @@ -0,0 +1,77 @@ +package constant + +import ( + "os" + "path/filepath" +) + +var ( + // ConfigPath 配置文件路径 + ConfigPath string + + // DataDir 数据目录 + DataDir string + + // DefaultDBPath 默认数据库路径 + DefaultDBPath string + + // WebDistDir 前端构建目录 + WebDistDir string + + // ScriptsWorkDir 脚本工作目录 + ScriptsWorkDir string +) + +func init() { + rootDir := ResolveAppRootDir() + ConfigPath = filepath.Clean(filepath.Join(rootDir, "configs", "config.ini")) + DataDir = filepath.Clean(filepath.Join(rootDir, "data")) + DefaultDBPath = filepath.Clean(filepath.Join(rootDir, "data", "taskpool.db")) + WebDistDir = filepath.Clean(filepath.Join(rootDir, "web", "dist")) + ScriptsWorkDir = filepath.Clean(filepath.Join(rootDir, "data", "scripts")) +} + +// ResolveAppRootDir 获取应用程序的绝对根目录路径。 +func ResolveAppRootDir() string { + // 1. 检查当前工作目录(CWD)及其上级目录 + if cwd, err := os.Getwd(); err == nil { + dir := cwd + for { + if _, err := os.Stat(filepath.Join(dir, "configs", "config.ini")); err == nil { + return dir + } + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + + // 2. 检查当前可执行文件路径及其上级目录 + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + for { + if _, err := os.Stat(filepath.Join(dir, "configs", "config.ini")); err == nil { + return dir + } + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + + // 3. 兜底回退到当前工作目录 + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "." +} diff --git a/internal/constant/sentence.go b/internal/constant/sentence.go new file mode 100644 index 0000000..c615e4b --- /dev/null +++ b/internal/constant/sentence.go @@ -0,0 +1,90 @@ +package constant + +import ( + "bytes" + _ "embed" + "encoding/json" + "math/rand" + "sync" +) + +//go:embed sentence1-10000.json +var sentenceData []byte + +type Sentence struct { + Name string `json:"name"` + From string `json:"from"` +} + +var ( + lineCount int + lineOffsets []int64 + sentenceOnce sync.Once +) + +// initSentences 初始化:统计行数和记录每行偏移 +func initSentences() { + sentenceOnce.Do(func() { + data := sentenceData + var offset int64 = 0 + for { + // 记录当前行的起始位置 + lineOffsets = append(lineOffsets, offset) + lineCount++ + + // 寻找下一个换行符 + idx := bytes.IndexByte(data[offset:], '\n') + if idx == -1 { + // 最后一行没有换行符 + break + } + + // 移动到下一行的起始位置 + offset += int64(idx) + 1 + if offset >= int64(len(data)) { + break + } + } + }) +} + +// GetRandomSentence 随机获取一条古诗词 +func GetRandomSentence() string { + initSentences() + if lineCount <= 0 { + return "欢迎使用任务池" + } + + targetIndex := rand.Intn(lineCount) + start := lineOffsets[targetIndex] + + // 确定当前行的结束位置 + var end int64 + if targetIndex < lineCount-1 { + end = lineOffsets[targetIndex+1] + } else { + end = int64(len(sentenceData)) + } + + // 提取行并清理两端的空白字符(包括 \r, \n) + line := bytes.TrimSpace(sentenceData[start:end]) + if len(line) == 0 { + return "欢迎使用任务池" + } + + var sData []string + if err := json.Unmarshal(line, &sData); err == nil && len(sData) >= 1 { + name := sData[0] + from := "" + if len(sData) >= 2 { + from = sData[1] + } + + if from != "" { + return "\"" + name + "\"—— " + from + } + return name + } + + return "欢迎使用任务池" +} diff --git a/internal/constant/sentence1-10000.json b/internal/constant/sentence1-10000.json new file mode 100644 index 0000000..974cae8 --- /dev/null +++ b/internal/constant/sentence1-10000.json @@ -0,0 +1,10000 @@ +["山有木兮木有枝,心悦君兮君不知。","佚名《越人歌》"] +["人生若只如初见,何事秋风悲画扇。","纳兰性德《木兰词·拟古决绝词柬友》"] +["十年生死两茫茫,不思量,自难忘。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["曾经沧海难为水,除却巫山不是云。","元稹《离思五首·其四》"] +["玲珑骰子安红豆,入骨相思知不知。","温庭筠《南歌子词二首 / 新添声杨柳枝词》"] +["人生如逆旅,我亦是行人。","苏轼《临江仙·送钱穆父》"] +["只愿君心似我心,定不负相思意。","李之仪《卜算子·我住长江头》"] +["平生不会相思,才会相思,便害相思。","徐再思《折桂令·春情》"] +["愿得一心人,白头不相离。","卓文君《白头吟》"] +["山无陵,江水为竭。冬雷震震,夏雨雪。天地合,乃敢与君绝。","佚名《上邪》"] +["两情若是久长时,又岂在朝朝暮暮。","秦观《鹊桥仙·纤云弄巧》"] +["入我相思门,知我相思苦。","李白《三五七言 / 秋风词》"] +["去年今日此门中,人面桃花相映红。","崔护《题都城南庄》"] +["人生自是有情痴,此恨不关风与月。","欧阳修《玉楼春·尊前拟把归期说》"] +["雨打梨花深闭门,忘了青春,误了青春。","唐寅《一剪梅·雨打梨花深闭门》"] +["花自飘零水自流。一种相思,两处闲愁。","李清照《一剪梅·红藕香残玉簟秋》"] +["一日不见兮,思之如狂。","司马相如《凤求凰 / 琴歌》"] +["问世间,情为何物,直教生死相许?","元好问《摸鱼儿·雁丘词 / 迈陂塘》"] +["身无彩凤双飞翼,心有灵犀一点通。","李商隐《无题·昨夜星辰昨夜风》"] +["世间无限丹青手,一片伤心画不成。","高蟾《金陵晚望》"] +["少年不识愁滋味,爱上层楼。爱上层楼。为赋新词强说愁。","辛弃疾《丑奴儿·书博山道中壁》"] +["执子之手,与子偕老。","佚名《击鼓》"] +["滚滚长江东逝水,浪花淘尽英雄。","杨慎《临江仙·滚滚长江东逝水》"] +["一往情深深几许?深山夕照深秋雨。","纳兰性德《蝶恋花·出塞》"] +["林花谢了春红,太匆匆。无奈朝来寒雨,晚来风。","李煜《相见欢·林花谢了春红》"] +["一生大笑能几回,斗酒相逢须醉倒。","岑参《凉州馆中与诸判官夜集》"] +["此情可待成追忆?只是当时已惘然。","李商隐《锦瑟》"] +["自在飞花轻似梦,无边丝雨细如愁。","秦观《浣溪沙·漠漠轻寒上小楼》"] +["思悠悠,恨悠悠,恨到归时方始休。","白居易《长相思·汴水流》"] +["春风得意马蹄疾,一日看尽长安花。","孟郊《登科后》"] +["一声梧叶一声秋,一点芭蕉一点愁,三更归梦三更后。","徐再思《水仙子·夜雨》"] +["春宵一刻值千金,花有清香月有阴。","苏轼《春宵·春宵一刻值千金》"] +["菩提本无树,明镜亦非台。","惠能《菩提偈》"] +["人面不知何处去,桃花依旧笑春风。","崔护《题都城南庄》"] +["时光只解催人老,不信多情,长恨离亭,泪滴春衫酒易醒。","晏殊《采桑子·时光只解催人老》"] +["浮云一别后,流水十年间。","韦应物《淮上喜会梁川故人 / 淮上喜会梁州故人》"] +["疏影横斜水清浅,暗香浮动月黄昏。","林逋《山园小梅·其一》"] +["昨夜西风凋碧树。独上高楼,望尽天涯路。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["若是前生未有缘,待重结、来生愿。","乐婉《卜算子·答施》"] +["抽刀断水水更流,举杯消愁愁更愁。","李白《宣州谢脁楼饯别校书叔云 / 陪侍御叔华登楼歌》"] +["取次花丛懒回顾,半缘修道半缘君。","元稹《离思五首·其四》"] +["怕相思,已相思,轮到相思没处辞,眉间露一丝。","俞彦《长相思·折花枝》"] +["溪云初起日沉阁,山雨欲来风满楼。","许浑《咸阳城东楼 / 咸阳城西楼晚眺 / 西门》"] +["似此星辰非昨夜,为谁风露立中宵。","黄景仁《绮怀》"] +["近水楼台先得月,向阳花木易为春。","苏麟《断句》"] +["还君明珠双泪垂,恨不相逢未嫁时。","张籍《节妇吟·寄东平李司空师道》"] +["问君能有几多愁?恰似一江春水向东流。","李煜《虞美人·春花秋月何时了》"] +["一骑红尘妃子笑,无人知是荔枝来。","杜牧《过华清宫绝句三首》"] +["枯藤老树昏鸦,小桥流水人家,古道西风瘦马。","马致远《天净沙·秋思》"] +["天涯地角有穷时,只有相思无尽处。","晏殊《玉楼春·春恨》"] +["夜月一帘幽梦,春风十里柔情。","秦观《八六子·倚危亭》"] +["落花人独立,微雨燕双飞。","晏几道《临江仙·梦后楼台高锁》"] +["君埋泉下泥销骨,我寄人间雪满头。","白居易《梦微之》"] +["行到水穷处,坐看云起时。","王维《终南别业 / 初至山中 / 入山寄城中故人》"] +["仰天大笑出门去,我辈岂是蓬蒿人。","李白《南陵别儿童入京》"] +["空山新雨后,天气晚来秋。","王维《山居秋暝》"] +["沾衣欲湿杏花雨,吹面不寒杨柳风。","志南《绝句》"] +["今人不见古时月,今月曾经照古人。","李白《把酒问月·故人贾淳令予问之》"] +["云想衣裳花想容,春风拂槛露华浓。","李白《清平调·其一》"] +["休对故人思故国,且将新火试新茶。诗酒趁年华。","苏轼《望江南·超然台作》"] +["凤凰台上凤凰游,凤去台空江自流。","李白《登金陵凤凰台》"] +["黄沙百战穿金甲,不破楼兰终不还。","王昌龄《从军行七首·其四》"] +["红酥手,黄縢酒,满城春色宫墙柳。","陆游《钗头凤·红酥手》"] +["直道相思了无益,未妨惆怅是清狂。","李商隐《无题·重帏深下莫愁堂》"] +["独立寒秋,湘江北去,橘子洲头。","毛泽东《沁园春·长沙》"] +["宁可枝头抱香死,何曾吹落北风中。","郑思肖《画菊》"] +["劝君莫惜金缕衣,劝君惜取少年时。","杜秋娘《金缕衣》"] +["同是天涯沦落人,相逢何必曾相识!","白居易《琵琶行 / 琵琶引》"] +["被酒莫惊春睡重,赌书消得泼茶香,当时只道是寻常。","纳兰性德《浣溪沙·谁念西风独自凉》"] +["人言落日是天涯,望极天涯不见家。","李觏《乡思》"] +["醉后不知天在水,满船清梦压星河。","唐温如《题龙阳县青草湖》"] +["青山依旧在,几度夕阳红。","杨慎《临江仙·滚滚长江东逝水》"] +["桃李春风一杯酒,江湖夜雨十年灯。","黄庭坚《寄黄几复》"] +["桃之夭夭,灼灼其华。","佚名《桃夭》"] +["小楼一夜听春雨,深巷明朝卖杏花。","陆游《临安春雨初霁》"] +["采得百花成蜜后,为谁辛苦为谁甜。","罗隐《蜂》"] +["春蚕到死丝方尽,蜡炬成灰泪始干。","李商隐《无题·相见时难别亦难》"] +["换我心,为你心,始知相忆深。","顾夐《诉衷情·永夜抛人何处去》"] +["男儿何不带吴钩,收取关山五十州。","李贺《南园十三首·其五》"] +["寂寞空庭春欲晚,梨花满地不开门。","刘方平《春怨》"] +["多情自古伤离别,更那堪冷落清秋节!","柳永《雨霖铃·寒蝉凄切》"] +["夜来幽梦忽还乡。小轩窗。正梳妆。相顾无言,惟有泪千行。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["海上生明月,天涯共此时。","张九龄《望月怀远》"] +["莫愁前路无知己,天下谁人不识君。","高适《别董大二首》"] +["月上柳梢头,人约黄昏后。","欧阳修《生查子·元夕》"] +["闻君有两意,故来相决绝。","卓文君《白头吟》"] +["人间四月芳菲尽,山寺桃花始盛开。","白居易《大林寺桃花》"] +["生当复来归,死当长相思。","佚名《留别妻》"] +["江碧鸟逾白,山青花欲燃。","杜甫《绝句二首》"] +["醉卧沙场君莫笑,古来征战几人回?","王翰《凉州词二首·其一》"] +["当年不肯嫁春风,无端却被秋风误。","贺铸《芳心苦·杨柳回塘》"] +["落霞与孤鹜齐飞,秋水共长天一色。","王勃《滕王阁序》"] +["心似双丝网,中有千千结。","张先《千秋岁·数声鶗鴂》"] +["美人自刎乌江岸,战火曾烧赤壁山,将军空老玉门关。","张可久《卖花声·怀古》"] +["东风夜放花千树。更吹落、星如雨。","辛弃疾《青玉案·元夕》"] +["不经一番寒彻骨,怎得梅花扑鼻香。","黄蘖禅师《上堂开示颂》"] +["风萧萧兮易水寒,壮士一去兮不复还。","佚名《荆轲歌 / 渡易水歌》"] +["日日思君不见君,共饮长江水。","李之仪《卜算子·我住长江头》"] +["月有盈亏花有开谢,想人生最苦离别。","张鸣善《普天乐·咏世》"] +["二十四桥明月夜,玉人何处教吹箫?","杜牧《寄扬州韩绰判官》"] +["落红不是无情物,化作春泥更护花。","龚自珍《己亥杂诗·其五》"] +["世事漫随流水,算来一梦浮生。","李煜《乌夜啼·昨夜风兼雨》"] +["锦瑟无端五十弦,一弦一柱思华年。","李商隐《锦瑟》"] +["人到情多情转薄,而今真个悔多情。","纳兰性德《山花子·风絮飘残已化萍》"] +["渺万里层云,千山暮雪,只影向谁去?","元好问《摸鱼儿·雁丘词 / 迈陂塘》"] +["独自莫凭栏,无限江山,别时容易见时难。","李煜《浪淘沙令·帘外雨潺潺》"] +["衰兰送客咸阳道。天若有情天亦老。","贺铸《行路难·缚虎手》"] +["美人卷珠帘,深坐颦蛾眉。","李白《怨情》"] +["万事到头都是梦,休休。明日黄花蝶也愁。","苏轼《南乡子·重九涵辉楼呈徐君猷》"] +["时人不识凌云木,直待凌云始道高。","杜荀鹤《小松》"] +["泪眼问花花不语,乱红飞过秋千去。","欧阳修《蝶恋花·庭院深深深几许》"] +["海水梦悠悠,君愁我亦愁。","佚名《西洲曲》"] +["酒逢知己千杯少,话不投机半句多。","欧阳修《春日西湖寄谢法曹韵》"] +["十年磨一剑,霜刃未曾试。","贾岛《剑客 / 述剑》"] +["盈盈一水间,脉脉不得语。","佚名《迢迢牵牛星》"] +["相思相见知何日?此时此夜难为情!","李白《三五七言 / 秋风词》"] +["大江东去,浪淘尽,千古风流人物。","苏轼《念奴娇·赤壁怀古》"] +["苟利国家生死以,岂因祸福避趋之!","林则徐《赴戍登程口占示家人·其二》"] +["呜呼!楚虽三户能亡秦,岂有堂堂中国空无人!","陆游《金错刀行》"] +["世人结交须黄金,黄金不多交不深。","张谓《题长安壁主人》"] +["竹外桃花三两枝,春江水暖鸭先知。","苏轼《惠崇春江晚景 / 惠崇春江晓景》"] +["白日放歌须纵酒,青春作伴好还乡。","杜甫《闻官军收河南河北》"] +["无可奈何花落去,似曾相识燕归来。","晏殊《浣溪沙·一曲新词酒一杯》"] +["射人先射马,擒贼先擒王。","杜甫《前出塞九首·其六》"] +["夕阳无限好,只是近黄昏。","李商隐《乐游原 / 登乐游原》"] +["梅须逊雪三分白,雪却输梅一段香。","卢梅坡《雪梅·其一》"] +["待到秋来九月八,我花开后百花杀。","黄巢《不第后赋菊》"] +["遇酒且呵呵,人生能几何!","韦庄《菩萨蛮·劝君今夜须沈醉》"] +["纸上得来终觉浅,绝知此事要躬行。","陆游《冬夜读书示子聿》"] +["长风破浪会有时,直挂云帆济沧海。","李白《行路难·其一》"] +["天长地久有时尽,此恨绵绵无绝期。","白居易《长恨歌》"] +["露从今夜白,月是故乡明。","杜甫《月夜忆舍弟》"] +["把酒祝东风。且共从容。","欧阳修《浪淘沙·把酒祝东风》"] +["物是人非事事休,欲语泪先流。","李清照《武陵春·春晚》"] +["酒入愁肠,化作相思泪。","范仲淹《苏幕遮·怀旧》"] +["欲把相思说似谁,浅情人不知。","晏几道《长相思·长相思》"] +["系我一生心,负你千行泪。","柳永《忆帝京·薄衾小枕凉天气》"] +["沉舟侧畔千帆过,病树前头万木春。","刘禹锡《酬乐天扬州初逢席上见赠》"] +["相思只在:丁香枝上,豆蔻梢头。","王雱《眼儿媚·杨柳丝丝弄轻柔》"] +["山重水复疑无路,柳暗花明又一村。","陆游《游山西村》"] +["晓看天色暮看云,行也思君,坐也思君。","唐寅《一剪梅·雨打梨花深闭门》"] +["相见时难别亦难,东风无力百花残。","李商隐《无题·相见时难别亦难》"] +["春心莫共花争发,一寸相思一寸灰!","李商隐《无题·飒飒东风细雨来》"] +["曾与美人桥上别,恨无消息到今朝。","刘禹锡《杨柳枝 / 柳枝词》"] +["肠已断,泪难收。相思重上小红楼。","辛弃疾《鹧鸪天·晚日寒鸦一片愁》"] +["山外青山楼外楼,西湖歌舞几时休?","林升《题临安邸》"] +["伤心桥下春波绿,曾是惊鸿照影来。","陆游《沈园二首》"] +["花无人戴,酒无人劝,醉也无人管。","黄公绍《青玉案·年年社日停针线》"] +["流水落花春去也,天上人间。","李煜《浪淘沙令·帘外雨潺潺》"] +["山一程,水一程,身向榆关那畔行,夜深千帐灯。","纳兰性德《长相思·山一程》"] +["无边落木萧萧下,不尽长江滚滚来。","杜甫《登高》"] +["寻寻觅觅,冷冷清清,凄凄惨惨戚戚。","李清照《声声慢·寻寻觅觅》"] +["兰有秀兮菊有芳,怀佳人兮不能忘。","刘彻《秋风辞》"] +["少年易老学难成,一寸光阴不可轻。","朱熹《劝学诗 / 偶成》"] +["读书不觉已春深,一寸光阴一寸金。","王贞白《白鹿洞二首·其一》"] +["韶华不为少年留。恨悠悠。几时休。","秦观《江城子·西城杨柳弄春柔》"] +["相思了无益,悔当初相见。","朱彝尊《忆少年·飞花时节》"] +["力拔山兮气盖世。时不利兮骓不逝。","项羽《垓下歌》"] +["渐行渐远渐无书,水阔鱼沉何处问。","欧阳修《玉楼春·别后不知君远近》"] +["落叶他乡树,寒灯独夜人。","马戴《灞上秋居》"] +["旧时王谢堂前燕,飞入寻常百姓家。","刘禹锡《乌衣巷》"] +["明月几时有?把酒问青天。","苏轼《水调歌头·丙辰中秋》"] +["思君如满月,夜夜减清辉。","张九龄《赋得自君之出矣》"] +["悠悠天宇旷,切切故乡情。","张九龄《西江夜行》"] +["洛阳亲友如相问,一片冰心在玉壶。","王昌龄《芙蓉楼送辛渐》"] +["无情不似多情苦。一寸还成千万缕。","晏殊《玉楼春·春恨》"] +["窈窕淑女,君子好逑。","佚名《关雎》"] +["结发为夫妻,恩爱两不疑。","佚名《留别妻》"] +["慈母手中线,游子身上衣。","孟郊《游子吟 / 迎母漂上作》"] +["会当凌绝顶,一览众山小。","杜甫《望岳》"] +["试问岭南应不好。却道。此心安处是吾乡。","苏轼《少年游·南海归赠王定国侍人寓娘》"] +["采菊东篱下,悠然见南山。","陶渊明《饮酒·其五》"] +["天街小雨润如酥,草色遥看近却无。","韩愈《早春呈水部张十八员外 / 初春小雨 / 早春》"] +["他年我若为青帝,报与桃花一处开。","黄巢《题菊花》"] +["以色事他人,能得几时好。","李白《妾薄命》"] +["人闲桂花落,夜静春山空。","王维《鸟鸣涧》"] +["但愿人长久,千里共婵娟。","苏轼《水调歌头·丙辰中秋》"] +["海内存知己,天涯若比邻。","王勃《送杜少府之任蜀州 / 送杜少府之任蜀川》"] +["枝上柳绵吹又少。天涯何处无芳草。","苏轼《蝶恋花·春景》"] +["夜阑卧听风吹雨,铁马冰河入梦来。","陆游《十一月四日风雨大作》"] +["花间一壶酒,独酌无相亲。","李白《月下独酌四首·其一》"] +["远看山有色,近听水无声。","王维《画》"] +["黑发不知勤学早,白首方悔读书迟。","颜真卿《劝学诗》"] +["世事短如春梦,人情薄似秋云。","朱敦儒《西江月·世事短如春梦》"] +["危楼高百尺,手可摘星辰。","李白《夜宿山寺》"] +["劝君更尽一杯酒,西出阳关无故人。","王维《渭城曲 / 送元二使安西》"] +["咬定青山不放松,立根原在破岩中。","郑燮《竹石》"] +["等闲识得东风面,万紫千红总是春。","朱熹《春日》"] +["沧海月明珠有泪,蓝田日暖玉生烟。","李商隐《锦瑟》"] +["多情只有春庭月,犹为离人照落花。","张泌《寄人》"] +["造物无言却有情,每于寒尽觉春生。","张维屏《新雷》"] +["晚来天欲雪,能饮一杯无。","白居易《问刘十九》"] +["江南可采莲,莲叶何田田。","佚名《江南》"] +["小荷才露尖尖角,早有蜻蜓立上头。","杨万里《小池》"] +["人生亦有命,安能行叹复坐愁?","鲍照《拟行路难·其四》"] +["云中谁寄锦书来,雁字回时,月满西楼。","李清照《一剪梅·红藕香残玉簟秋》"] +["春江潮水连海平,海上明月共潮生。","张若虚《春江花月夜》"] +["秦时明月汉时关,万里长征人未还。","王昌龄《出塞二首·其一》"] +["昔去雪如花,今来花似雪。","范云《别诗》"] +["朔风如解意,容易莫摧残。","崔道融《梅花》"] +["大漠孤烟直,长河落日圆。","王维《使至塞上》"] +["借问江潮与海水,何似君情与妾心?","白居易《浪淘沙·借问江潮与海水》"] +["春风又绿江南岸,明月何时照我还?","王安石《泊船瓜洲》"] +["兰陵美酒郁金香,玉碗盛来琥珀光。","李白《客中行 / 客中作》"] +["七八个星天外,两三点雨山前。","辛弃疾《西江月·夜行黄沙道中》"] +["水晶帘动微风起,满架蔷薇一院香。","高骈《山亭夏日》"] +["春风十里扬州路,卷上珠帘总不如。","杜牧《赠别》"] +["莫唱当年长恨歌,人间亦自有银河。","袁枚《马嵬》"] +["宣父犹能畏后生,丈夫未可轻年少。","李白《上李邕》"] +["伯牙鼓琴,志在高山","佚名《伯牙绝弦》"] +["故人西辞黄鹤楼,烟花三月下扬州。","李白《黄鹤楼送孟浩然之广陵》"] +["人生自古谁无死?留取丹心照汗青。","文天祥《过零丁洋》"] +["欲买桂花同载酒,终不是、少年游。","刘过《唐多令·芦叶满汀洲》"] +["青枫江上秋帆远,白帝城边古木疏。","高适《送李少府贬峡中王少府贬长沙》"] +["毕竟西湖六月中,风光不与四时同。","杨万里《晓出净慈寺送林子方》"] +["一寸柔肠情几许?薄衾孤枕,梦回人静,彻晓潇潇雨。","惠洪《青玉案·丝槐烟柳长亭路》"] +["自是人生长恨,水长东。","李煜《相见欢·林花谢了春红》"] +["出师未捷身先死,长使英雄泪满襟。","杜甫《蜀相》"] +["劝君莫惜花前醉,今年花谢,明年花谢,白了人头。","赵秉文《青杏儿·风雨替花愁》"] +["七夕今宵看碧霄,牵牛织女渡河桥。","林杰《乞巧》"] +["商女不知亡国恨,隔江犹唱后庭花。","杜牧《泊秦淮》"] +["两个黄鹂鸣翠柳,一行白鹭上青天。","杜甫《绝句》"] +["君知妾有夫,赠妾双明珠。","张籍《节妇吟·寄东平李司空师道》"] +["昨夜星辰昨夜风,画楼西畔桂堂东。","李商隐《无题·昨夜星辰昨夜风》"] +["绿蚁新醅酒,红泥小火炉。","白居易《问刘十九》"] +["东边日出西边雨,道是无晴却有晴。","刘禹锡《竹枝词二首·其一》"] +["谁家玉笛暗飞声,散入春风满洛城。","李白《春夜洛城闻笛 / 春夜洛阳城闻笛》"] +["墙角数枝梅,凌寒独自开。","王安石《梅花 / 梅》"] +["蝉噪林逾静,鸟鸣山更幽。","王籍《入若耶溪》"] +["日色欲尽花含烟,月明欲素愁不眠。","李白《长相思·其二》"] +["愿为西南风,长逝入君怀。","曹植《明月上高楼》"] +["近乡情更怯,不敢问来人。","宋之问《渡汉江》"] +["愿得此身长报国,何须生入玉门关。","戴叔伦《塞上曲二首·其二》"] +["谁念西风独自凉,萧萧黄叶闭疏窗,沉思往事立残阳。","纳兰性德《浣溪沙·谁念西风独自凉》"] +["君问归期未有期,巴山夜雨涨秋池。","李商隐《夜雨寄北》"] +["江畔何人初见月?江月何年初照人?","张若虚《春江花月夜》"] +["十年一觉扬州梦,赢得青楼薄幸名。","杜牧《遣怀》"] +["天生我材必有用,千金散尽还复来。","李白《将进酒》"] +["我住长江头,君住长江尾。","李之仪《卜算子·我住长江头》"] +["萧萧梧叶送寒声,江上秋风动客情。","叶绍翁《夜书所见》"] +["相逢一醉是前缘,风雨散、飘然何处。","苏轼《鹊桥仙·七夕》"] +["执手相看泪眼,竟无语凝噎。","柳永《雨霖铃·寒蝉凄切》"] +["世情薄,人情恶,雨送黄昏花易落。","唐婉《钗头凤·世情薄》"] +["南朝四百八十寺,多少楼台烟雨中。","杜牧《江南春》"] +["盛年不重来,一日难再晨。","陶渊明《杂诗·人生无根蒂》"] +["明月松间照,清泉石上流。","王维《山居秋暝》"] +["葡萄美酒夜光杯,欲饮琵琶马上催。","王翰《凉州词二首·其一》"] +["忽见陌头杨柳色,悔教夫婿觅封侯。","王昌龄《闺怨》"] +["相恨不如潮有信,相思始觉海非深。","白居易《浪淘沙·借问江潮与海水》"] +["山不在高,有仙则名。水不在深,有龙则灵。","刘禹锡《陋室铭》"] +["接天莲叶无穷碧,映日荷花别样红。","杨万里《晓出净慈寺送林子方》"] +["墙外行人,墙里佳人笑。","苏轼《蝶恋花·春景》"] +["松花酿酒,春水煎茶。","张可久《人月圆·山中书事》"] +["江南几度梅花发,人在天涯鬓已斑。","刘著《鹧鸪天·雪照山城玉指寒》"] +["大漠沙如雪,燕山月似钩。","李贺《马诗二十三首·其五》"] +["满目山河空念远,落花风雨更伤春。","晏殊《浣溪沙·一向年光有限身》"] +["若教眼底无离恨,不信人间有白头。","辛弃疾《鹧鸪天·晚日寒鸦一片愁》"] +["红泪偷垂,满眼春风百事非。","纳兰性德《采桑子·当时错》"] +["此情无计可消除,才下眉头,却上心头。","李清照《一剪梅·红藕香残玉簟秋》"] +["好雨知时节,当春乃发生。","杜甫《春夜喜雨》"] +["水光潋滟晴方好,山色空蒙雨亦奇。","苏轼《饮湖上初晴后雨二首·其二》"] +["日出江花红胜火,春来江水绿如蓝。","白居易《忆江南·江南好》"] +["易求无价宝,难得有情郎。","鱼玄机《赠邻女 / 寄李亿员外》"] +["去年花里逢君别,今日花开已一年。","韦应物《寄李儋元锡》"] +["风一更,雪一更,聒碎乡心梦不成,故园无此声。","纳兰性德《长相思·山一程》"] +["身既死兮神以灵,魂魄毅兮为鬼雄。","屈原《国殇》"] +["八阵图名成卧龙,六韬书功在飞熊。","查德卿《蟾宫曲·怀古》"] +["羌笛何须怨杨柳,春风不度玉门关。","王之涣《凉州词二首·其一》"] +["数人世相逢,百年欢笑,能得几回又。","何梦桂《摸鱼儿·记年时人人何处》"] +["青山绿水,白草红叶黄花。","白朴《天净沙·秋》"] +["新竹高于旧竹枝,全凭老干为扶持。","郑燮《新竹》"] +["秋阴不散霜飞晚,留得枯荷听雨声。","李商隐《宿骆氏亭寄怀崔雍崔衮》"] +["南风知我意,吹梦到西洲。","佚名《西洲曲》"] +["绿杨烟外晓寒轻,红杏枝头春意闹。","宋祁《玉楼春·春景》"] +["若待上林花似锦,出门俱是看花人。","杨巨源《城东早春》"] +["平生不下泪,于此泣无穷。","李白《江夏别宋之悌》"] +["金风玉露一相逢,便胜却人间无数。","秦观《鹊桥仙·纤云弄巧》"] +["我居北海君南海,寄雁传书谢不能。","黄庭坚《寄黄几复》"] +["柳庭风静人眠昼,昼眠人静风庭柳。","苏轼《菩萨蛮·回文夏闺怨》"] +["离离原上草,一岁一枯荣。","白居易《草 / 赋得古原草送别》"] +["嫦娥应悔偷灵药,碧海青天夜夜心。","李商隐《嫦娥》"] +["月落乌啼霜满天,江枫渔火对愁眠。","张继《枫桥夜泊 / 夜泊枫江》"] +["一道残阳铺水中,半江瑟瑟半江红。","白居易《暮江吟》"] +["博观而约取,厚积而薄发","苏轼《稼说送张琥》"] +["春水碧于天,画船听雨眠。","韦庄《菩萨蛮·人人尽说江南好》"] +["孤灯不明思欲绝,卷帷望月空长叹。","李白《长相思·其一》"] +["山远天高烟水寒,相思枫叶丹。","李煜《长相思·一重山》"] +["江南无所有,聊赠一枝春。","陆凯《赠范晔诗》"] +["老来情味减,对别酒、怯流年。","辛弃疾《木兰花慢·滁州送范倅》"] +["宁不知倾城与倾国。佳人难再得。","李延年《李延年歌》"] +["月儿弯弯照九州,几家欢乐几家愁。","佚名《月儿弯弯照九州》"] +["不茶不饭,不言不语,一味供他憔悴。","蜀妓《鹊桥仙·说盟说誓》"] +["相思一夜梅花发,忽到窗前疑是君。","卢仝《有所思》"] +["情人怨遥夜,竟夕起相思。","张九龄《望月怀远》"] +["不识庐山真面目,只缘身在此山中。","苏轼《题西林壁》"] +["多情却被无情恼,今夜还如昨夜长。","元好问《鹧鸪天·候馆灯昏雨送凉》"] +["似花还似非花,也无人惜从教坠。","苏轼《水龙吟·次韵章质夫杨花词》"] +["舞低杨柳楼心月,歌尽桃花扇底风。","晏几道《鹧鸪天·彩袖殷勤捧玉钟》"] +["莫笑贱贫夸富贵,共成枯骨两何如?","白居易《放言五首·其四》"] +["若言琴上有琴声,放在匣中何不鸣?","苏轼《琴诗》"] +["春潮带雨晚来急,野渡无人舟自横。","韦应物《滁州西涧》"] +["风淅淅,雨纤纤。难怪春愁细细添。","纳兰性德《赤枣子·风淅淅》"] +["位卑未敢忘忧国,事定犹须待阖棺。","陆游《病起书怀》"] +["悲莫悲生离别,乐莫乐新相识,儿女古今情。","辛弃疾《水调歌头·壬子三山被召陈端仁给事饮饯席上作》"] +["水是眼波横,山是眉峰聚。","王观《卜算子·送鲍浩然之浙东》"] +["试玉要烧三日满,辨材须待七年期。","白居易《放言五首·其三》"] +["古人学问无遗力,少壮工夫老始成。","陆游《冬夜读书示子聿》"] +["三更灯火五更鸡,正是男儿读书时。","颜真卿《劝学诗》"] +["李白如今已仙去,月在青天几圆缺?","唐寅《把酒对月歌》"] +["天平山上白云泉,云自无心水自闲。","白居易《白云泉》"] +["相思相望不相亲,天为谁春。","纳兰性德《画堂春·一生一代一双人》"] +["当君怀归日,是妾断肠时。","李白《春思》"] +["天阶夜色凉如水,卧看牵牛织女星。","杜牧《秋夕》"] +["无情最是台城柳,依旧烟笼十里堤。","韦庄《台城》"] +["无言独上西楼,月如钩。寂寞梧桐深院锁清秋。","李煜《相见欢·无言独上西楼》"] +["西塞山前白鹭飞,桃花流水鳜鱼肥。","张志和《渔歌子·西塞山前白鹭飞》"] +["汴水流,泗水流,流到瓜州古渡头。吴山点点愁。","白居易《长相思·汴水流》"] +["小楼昨夜又东风,故国不堪回首月明中。","李煜《虞美人·春花秋月何时了》"] +["忽如一夜春风来,千树万树梨花开。","岑参《白雪歌送武判官归京》"] +["松树千年终是朽,槿花一日自为荣。","白居易《放言五首·其五》"] +["少年听雨歌楼上。红烛昏罗帐。","蒋捷《虞美人·听雨》"] +["妆罢低声问夫婿,画眉深浅入时无。","朱庆馀《近试上张籍水部 / 近试上张水部 / 闺意献张水部》"] +["红豆生南国,春来发几枝。","王维《相思》"] +["迟日江山丽,春风花草香。","杜甫《绝句二首》"] +["路漫漫其修远兮,吾将上下而求索。","屈原《离骚》"] +["千金纵买相如赋,脉脉此情谁诉。","辛弃疾《摸鱼儿·更能消几番风雨》"] +["红颜未老恩先断,斜倚薰笼坐到明。","白居易《后宫词》"] +["缺月挂疏桐,漏断人初静。","苏轼《卜算子·黄州定慧院寓居作》"] +["昔我往矣,杨柳依依。","佚名《采薇》"] +["草木有本心,何求美人折!","张九龄《感遇十二首·其一》"] +["欲寄彩笺兼尺素。山长水阔知何处。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["胜日寻芳泗水滨,无边光景一时新。","朱熹《春日》"] +["此生谁料,心在天山,身老沧洲。","陆游《诉衷情·当年万里觅封侯》"] +["想当年,金戈铁马,气吞万里如虎。","辛弃疾《永遇乐·京口北固亭怀古》"] +["庭前落尽梧桐,水边开彻芙蓉。","朱庭玉《天净沙·秋》"] +["昆山玉碎凤凰叫,芙蓉泣露香兰笑。","李贺《李凭箜篌引》"] +["一寸相思千万绪。人间没个安排处。","李冠《蝶恋花·春暮》"] +["恰同学少年,风华正茂;书生意气,挥斥方遒。","毛泽东《沁园春·长沙》"] +["当时明月在,曾照彩云归。","晏几道《临江仙·梦后楼台高锁》"] +["今宵酒醒何处?杨柳岸,晓风残月。","柳永《雨霖铃·寒蝉凄切》"] +["莫道不销魂,帘卷西风,人比黄花瘦。","李清照《醉花阴·薄雾浓云愁永昼》"] +["千古兴亡多少事?悠悠。不尽长江滚滚流。","辛弃疾《南乡子·登京口北固亭有怀》"] +["稻花香里说丰年。听取蛙声一片。","辛弃疾《西江月·夜行黄沙道中》"] +["云母屏风烛影深,长河渐落晓星沉。","李商隐《嫦娥》"] +["荷叶生时春恨生,荷叶枯时秋恨成。","李商隐《暮秋独游曲江》"] +["思君如流水,何有穷已时。","徐干《室思》"] +["少无适俗韵,性本爱丘山。","陶渊明《归园田居·其一》"] +["功名万里外,心事一杯中。","高适《送李侍御赴安西》"] +["有情芍药含春泪,无力蔷薇卧晓枝。","秦观《春日》"] +["日啖荔枝三百颗,不辞长作岭南人。","苏轼《惠州一绝》"] +["宁为百夫长,胜作一书生。","杨炯《从军行》"] +["乱花渐欲迷人眼,浅草才能没马蹄。","白居易《钱塘湖春行》"] +["少小离家老大回,乡音无改鬓毛衰。","贺知章《回乡偶书二首·其一》"] +["枕上诗书闲处好,门前风景雨来佳。","李清照《摊破浣溪沙·病起萧萧两鬓华》"] +["停车坐爱枫林晚,霜叶红于二月花。","杜牧《山行》"] +["依旧,依旧,人与绿杨俱瘦。","秦观《如梦令·春景》"] +["君为女萝草,妾作菟丝花。","李白《古意》"] +["丈夫非无泪,不洒离别间。","陆龟蒙《别离》"] +["感时花溅泪,恨别鸟惊心。","杜甫《春望》"] +["千山鸟飞绝,万径人踪灭。","柳宗元《江雪》"] +["花落花开自有时,总赖东君主。","严蕊《卜算子·不是爱风尘》"] +["青山相待,白云相爱,梦不到紫罗袍共黄金带。","宋方壶《山坡羊·道情》"] +["乱山残雪夜,孤烛异乡人。","崔涂《除夜 / 巴山道中除夜书怀 / 除夜有怀》"] +["中秋谁与共孤光。把盏凄然北望。","苏轼《西江月·世事一场大梦》"] +["清风明月无人管,并作南楼一味凉。","黄庭坚《鄂州南楼书事》"] +["冰雪林中著此身,不同桃李混芳尘;","王冕《白梅》"] +["柴门闻犬吠,风雪夜归人。","刘长卿《逢雪宿芙蓉山主人》"] +["爆竹声中一岁除,春风送暖入屠苏。","王安石《元日》"] +["离别家乡岁月多,近来人事半消磨。","贺知章《回乡偶书二首》"] +["一别如斯,落尽梨花月又西。","纳兰性德《采桑子·当时错》"] +["为谁醉倒为谁醒,到今犹恨轻离别。","吕本中《踏莎行·雪似梅花》"] +["世间行乐亦如此,古来万事东流水。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["孤舟蓑笠翁,独钓寒江雪。","柳宗元《江雪》"] +["何处望神州?满眼风光北固楼。","辛弃疾《南乡子·登京口北固亭有怀》"] +["欲把西湖比西子,淡妆浓抹总相宜。","苏轼《饮湖上初晴后雨二首·其二》"] +["我也不登天子船,我也不上长安眠。","唐寅《把酒对月歌》"] +["儿童散学归来早,忙趁东风放纸鸢。","高鼎《村居》"] +["衰兰送客咸阳道,天若有情天亦老。","李贺《金铜仙人辞汉歌》"] +["一叫一回肠一断,三春三月忆三巴。","李白《宣城见杜鹃花 / 子规》"] +["臣心一片磁针石,不指南方不肯休。","文天祥《扬子江》"] +["青鸟不传云外信,丁香空结雨中愁。","李璟《摊破浣溪沙·手卷真珠上玉钩》"] +["日暮苍山远,天寒白屋贫。","刘长卿《逢雪宿芙蓉山主人》"] +["娶妻无媒毋须恨,书中有女颜如玉。","赵恒《劝学诗》"] +["鸟宿池边树,僧敲月下门。","贾岛《题李凝幽居》"] +["寒夜客来茶当酒,竹炉汤沸火初红。","杜耒《寒夜》"] +["草长莺飞二月天,拂堤杨柳醉春烟。","高鼎《村居》"] +["风吹柳花满店香,吴姬压酒唤客尝。","李白《金陵酒肆留别》"] +["人生得意须尽欢,莫使金樽空对月。","李白《将进酒》"] +["清明时节雨纷纷,路上行人欲断魂。","杜牧《清明》"] +["卧看满天云不动,不知云与我俱东。","陈与义《襄邑道中》"] +["星垂平野阔,月涌大江流。","杜甫《旅夜书怀》"] +["秋风起兮白云飞,草木黄落兮雁南归。","刘彻《秋风辞》"] +["我寄愁心与明月,随风直到夜郎西。","李白《闻王昌龄左迁龙标遥有此寄》"] +["黄河远上白云间,一片孤城万仞山。","王之涣《凉州词二首·其一》"] +["鹅湖山下稻粱肥,豚栅鸡栖半掩扉。","王驾《社日》"] +["梧桐叶上三更雨,叶叶声声是别离。","周紫芝《鹧鸪天·一点残红欲尽时》"] +["漠漠水田飞白鹭,阴阴夏木啭黄鹂。","王维《积雨辋川庄作 / 秋归辋川庄作》"] +["南国有佳人,容华若桃李。","曹植《杂诗·南国有佳人》"] +["千锤万凿出深山,烈火焚烧若等闲。","于谦《石灰吟》"] +["恨君不似江楼月,南北东西,南北东西,只有相随无别离。","吕本中《采桑子·恨君不似江楼月》"] +["感君缠绵意,系在红罗襦。","张籍《节妇吟·寄东平李司空师道》"] +["谁料同心结不成,翻就相思结。","夏完淳《卜算子·秋色到空闺》"] +["国破山河在,城春草木深。","杜甫《春望》"] +["生当作人杰,死亦为鬼雄。","李清照《夏日绝句》"] +["一壶酒,一竿身,快活如侬有几人。","李煜《渔父·浪花有意千里雪》"] +["海棠未雨,梨花先雪,一半春休。","王雱《眼儿媚·杨柳丝丝弄轻柔》"] +["随风潜入夜,润物细无声。","杜甫《春夜喜雨》"] +["人世几回伤往事,山形依旧枕寒流。","刘禹锡《西塞山怀古》"] +["草木也知愁,韶华竟白头。","曹雪芹《唐多令·柳絮》"] +["及时当勉励,岁月不待人。","陶渊明《杂诗·人生无根蒂》"] +["春城无处不飞花,寒食东风御柳斜。","韩翃《寒食 / 寒食日即事》"] +["壮心未与年俱老,死去犹能作鬼雄。","陆游《书愤五首·其二》"] +["春色满园关不住,一枝红杏出墙来。","叶绍翁《游园不值》"] +["看朱成碧思纷纷,憔悴支离为忆君。","武则天《如意娘》"] +["日照香炉生紫烟,遥看瀑布挂前川。","李白《望庐山瀑布》"] +["桃花潭水深千尺,不及汪伦送我情。","李白《赠汪伦》"] +["但使龙城飞将在,不教胡马度阴山。","王昌龄《出塞二首》"] +["举杯邀明月,对影成三人。","李白《月下独酌四首·其一》"] +["愿君多采撷,此物最相思。","王维《相思》"] +["长恨此身非我有,何时忘却营营。","苏轼《临江仙·夜饮东坡醒复醉》"] +["纵被无情弃,不能羞。","韦庄《思帝乡·春日游》"] +["晚日寒鸦一片愁。柳塘新绿却温柔。","辛弃疾《鹧鸪天·晚日寒鸦一片愁》"] +["三十功名尘与土,八千里路云和月。","岳飞《满江红·写怀》"] +["怒发冲冠,凭栏处、潇潇雨歇。","岳飞《满江红·写怀》"] +["花褪残红青杏小。燕子飞时,绿水人家绕。","苏轼《蝶恋花·春景》"] +["燕子来时新社,梨花落后清明。","晏殊《破阵子·春景》"] +["自古逢秋悲寂寥,我言秋日胜春朝。","刘禹锡《秋词》"] +["我欲穿花寻路,直入白云深处,浩气展虹霓。","黄庭坚《水调歌头·游览》"] +["皑如山上雪,皎若云间月。","卓文君《白头吟》"] +["江山代有才人出,各领风骚数百年。","赵翼《论诗五首·其二》"] +["迢迢牵牛星,皎皎河汉女。","佚名《迢迢牵牛星》"] +["浮生只合尊前老。雪满长安道。","舒亶《虞美人·寄公度》"] +["峨眉山月半轮秋,影入平羌江水流。","李白《峨眉山月歌》"] +["醉翁之意不在酒,在乎山水之间也。","欧阳修《醉翁亭记》"] +["四十年来家国,三千里地山河。","李煜《破阵子·四十年来家国》"] +["殷勤花下同携手。更尽杯中酒。美人不用敛蛾眉。","叶梦得《虞美人·雨后同干誉才卿置酒来禽花下作》"] +["黑云翻墨未遮山,白雨跳珠乱入船。","苏轼《六月二十七日望湖楼醉书》"] +["相看两不厌,只有敬亭山。","李白《独坐敬亭山》"] +["一片春愁待酒浇。江上舟摇,楼上帘招。","蒋捷《一剪梅·舟过吴江》"] +["只解沙场为国死,何须马革裹尸还。","徐锡麟《出塞》"] +["野径云俱黑,江船火独明。","杜甫《春夜喜雨》"] +["若似月轮终皎洁,不辞冰雪为卿热。","纳兰性德《蝶恋花·辛苦最怜天上月》"] +["池上碧苔三四点,叶底黄鹂一两声。","晏殊《破阵子·春景》"] +["袅袅城边柳,青青陌上桑。","张仲素《春闺思》"] +["遥想公瑾当年,小乔初嫁了,雄姿英发。","苏轼《念奴娇·赤壁怀古》"] +["儿童急走追黄蝶,飞入菜花无处寻。","杨万里《宿新市徐公店》"] +["了却君王天下事,赢得生前身后名。","辛弃疾《破阵子·为陈同甫赋壮词以寄之》"] +["明月出天山,苍茫云海间。","李白《关山月》"] +["梅花雪,梨花月,总相思。","张惠言《相见欢·年年负却花期》"] +["一别都门三改火,天涯踏尽红尘。","苏轼《临江仙·送钱穆父》"] +["鸟向平芜远近,人随流水东西。","刘长卿《谪仙怨·晴川落日初低》"] +["素衣莫起风尘叹,犹及清明可到家。","陆游《临安春雨初霁》"] +["众里寻他千百度。蓦然回首,那人却在,灯火阑珊处。","辛弃疾《青玉案·元夕》"] +["明月不谙离恨苦。斜光到晓穿朱户。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["羡青山有思,白鹤忘机。","汤恢《八声甘州·摘青梅荐酒》"] +["远上寒山石径斜,白云生处有人家。","杜牧《山行》"] +["家国兴亡自有时,吴人何苦怨西施。","罗隐《西施》"] +["天下英雄谁敌手?曹刘。生子当如孙仲谋。","辛弃疾《南乡子·登京口北固亭有怀》"] +["死去元知万事空,但悲不见九州同。","陆游《示儿》"] +["众鸟高飞尽,孤云独去闲。","李白《独坐敬亭山》"] +["黄鹤一去不复返,白云千载空悠悠。","崔颢《黄鹤楼 / 登黄鹤楼》"] +["范增一去无谋主,韩信原来是逐臣。","严遂成《乌江项王庙》"] +["风乍起,吹皱一池春水。","冯延巳《谒金门·风乍起》"] +["花门楼前见秋草,岂能贫贱相看老。","岑参《凉州馆中与诸判官夜集》"] +["暖风熏得游人醉,直把杭州作汴州。","林升《题临安邸》"] +["不畏浮云遮望眼,只缘身在最高层。","王安石《登飞来峰》"] +["北国风光,千里冰封,万里雪飘。","毛泽东《沁园春·雪》"] +["野旷天低树,江清月近人。","孟浩然《宿建德江》"] +["相见争如不见,多情何似无情。","司马光《西江月·宝髻松松挽就》"] +["烟笼寒水月笼沙,夜泊秦淮近酒家。","杜牧《泊秦淮》"] +["唯有门前镜湖水,春风不改旧时波。","贺知章《回乡偶书二首》"] +["忽忆故人今总老。贪梦好。茫然忘了邯郸道。","王安石《渔家傲·平岸小桥千嶂抱》"] +["轻汗微微透碧纨,明朝端午浴芳兰。","苏轼《浣溪沙·端午》"] +["年年岁岁花相似,岁岁年年人不同。","刘希夷《代悲白头翁 / 白头吟 / 有所思》"] +["恰如灯下,故人万里,归来对影。","黄庭坚《品令·茶词》"] +["昔时人已没,今日水犹寒。","骆宾王《于易水送人 / 于易水送别》"] +["千里莺啼绿映红,水村山郭酒旗风。","杜牧《江南春》"] +["谁道人生无再少?门前流水尚能西!休将白发唱黄鸡。","苏轼《浣溪沙·游蕲水清泉寺》"] +["青山隐隐水迢迢,秋尽江南草未凋。","杜牧《寄扬州韩绰判官》"] +["半生已分孤眠过,山枕檀痕涴。","纳兰性德《虞美人·曲阑深处重相见》"] +["朝辞白帝彩云间,千里江陵一日还。","李白《早发白帝城 / 白帝下江陵》"] +["连雨不知春去,一晴方觉夏深。","范成大《喜晴》"] +["少壮不努力,老大徒伤悲!","佚名《长歌行》"] +["有梅无雪不精神,有雪无诗俗了人。","卢梅坡《雪梅·其二》"] +["风雨替花愁。风雨罢,花也应休。","赵秉文《青杏儿·风雨替花愁》"] +["居高声自远,非是藉秋风。","虞世南《蝉》"] +["黄梅时节家家雨,青草池塘处处蛙。","赵师秀《约客 / 有约》"] +["绿树阴浓夏日长,楼台倒影入池塘。","高骈《山亭夏日》"] +["春日游,杏花吹满头。","韦庄《思帝乡·春日游》"] +["一重山,两重山。山远天高烟水寒,相思枫叶丹。","李煜《长相思·一重山》"] +["君若清路尘,妾若浊水泥;","曹植《明月上高楼》"] +["春未绿,鬓先丝。人间别久不成悲。","姜夔《鹧鸪天·元夕有所梦》"] +["醉别西楼醒不记。春梦秋云,聚散真容易。","晏几道《蝶恋花·醉别西楼醒不记》"] +["朝看水东流,暮看日西坠。","钱福《明日歌》"] +["几处早莺争暖树,谁家新燕啄春泥。","白居易《钱塘湖春行》"] +["别来春半,触目柔肠断。","李煜《清平乐·别来春半》"] +["欲将轻骑逐,大雪满弓刀。","卢纶《和张仆射塞下曲·其三》"] +["此夜曲中闻折柳,何人不起故园情。","李白《春夜洛城闻笛 / 春夜洛阳城闻笛》"] +["且就洞庭赊月色,将船买酒白云边。","李白《游洞庭湖五首·其二》"] +["九州生气恃风雷,万马齐喑究可哀。","龚自珍《己亥杂诗·其二百二十》"] +["零落成泥碾作尘,只有香如故。","陆游《卜算子·咏梅》"] +["青青园中葵,朝露待日晞。","佚名《长歌行》"] +["千磨万击还坚劲,任尔东西南北风。","郑燮《竹石》"] +["海日生残夜,江春入旧年。","王湾《次北固山下》"] +["年年岁岁望中秋,岁岁年年雾雨愁。","曾几《癸未八月十四日至十六夜月色皆佳》"] +["三分春色二分愁,更一分风雨。","叶清臣《贺圣朝·留别》"] +["只有天在上,更无山与齐。","寇准《咏华山》"] +["敕勒川,阴山下。天似穹庐,笼盖四野。","佚名《敕勒歌》"] +["问渠哪得清如许?为有源头活水来。","朱熹《活水亭观书有感二首·其一》"] +["诚知此恨人人有,贫贱夫妻百事哀。","元稹《遣悲怀三首·其二》"] +["春草明年绿,王孙归不归?","王维《送别 / 山中送别 / 送友》"] +["锄禾日当午,汗滴禾下土。","李绅《古风二首 / 悯农二首》"] +["不是花中偏爱菊,此花开尽更无花。","元稹《菊花》"] +["君不见走马川行雪海边,平沙莽莽黄入天。","岑参《走马川行奉送出师西征 / 走马川行奉送封大夫出师西征》"] +["人间万事,毫发常重泰山轻。","辛弃疾《水调歌头·壬子三山被召陈端仁给事饮饯席上作》"] +["一向年光有限身。等闲离别易销魂。酒筵歌席莫辞频。","晏殊《浣溪沙·一向年光有限身》"] +["一年将尽夜,万里未归人。","戴叔伦《除夜宿石头驿》"] +["雪消门外千山绿,花发江边二月晴。","欧阳修《春日西湖寄谢法曹歌》"] +["聚散匆匆,此恨年年有。","魏夫人《点绛唇·波上清风》"] +["驿外断桥边,寂寞开无主。","陆游《卜算子·咏梅》"] +["塞下秋来风景异,衡阳雁去无留意。","范仲淹《渔家傲·秋思》"] +["衣带渐宽终不悔,为伊消得人憔悴。","柳永《蝶恋花·伫倚危楼风细细》"] +["碧玉妆成一树高,万条垂下绿丝绦。","贺知章《咏柳 / 柳枝词》"] +["燕子不归春事晚,一汀烟雨杏花寒。","戴叔伦《苏溪亭》"] +["酒贱常愁客少,月明多被云妨。","苏轼《西江月·世事一场大梦》"] +["黄昏庭院柳啼鸦,记得那人,和月折梨花。","陈亮《虞美人·东风荡飏轻云缕》"] +["一种相思,两处闲愁。","李清照《一剪梅·红藕香残玉簟秋》"] +["白日依山尽,黄河入海流。","王之涣《登鹳雀楼》"] +["窗含西岭千秋雪,门泊东吴万里船。","杜甫《绝句》"] +["林暗草惊风,将军夜引弓。","卢纶《和张仆射塞下曲·其二》"] +["山河破碎风飘絮,身世浮沉雨打萍。","文天祥《过零丁洋》"] +["去年元夜时,花市灯如昼。","欧阳修《生查子·元夕》"] +["暗暗淡淡紫,融融冶冶黄。","李商隐《菊花》"] +["此生此夜不长好,明月明年何处看。","苏轼《阳关曲 / 中秋月》"] +["浮云终日行,游子久不至。","杜甫《梦李白二首·其二》"] +["难相见,易相别,又是玉楼花似雪。","韦庄《应天长·别来半岁音书绝》"] +["九曲黄河万里沙,浪淘风簸自天涯。","刘禹锡《浪淘沙·九曲黄河万里沙》"] +["忆君心似西江水,日夜东流无歇时。","鱼玄机《江陵愁望寄子安》"] +["两鬓可怜青,只为相思老。","晏几道《生查子·关山魂梦长》"] +["独自凄凉还自遣,自制离愁。","龚自珍《浪淘沙·写梦》"] +["有情不管别离久。情在相逢终有。","晏几道《秋蕊香·池苑清阴欲就》"] +["总为浮云能蔽日,长安不见使人愁。","李白《登金陵凤凰台》"] +["还卿一钵无情泪,恨不相逢未剃时。","苏曼殊《本事诗十首·选二》"] +["欲写彩笺书别怨。泪痕早已先书满。","晏几道《蝶恋花·黄菊开时伤聚散》"] +["青青子衿,悠悠我心。","佚名《子衿》"] +["多情却似总无情,唯觉樽前笑不成。","杜牧《赠别》"] +["家在梦中何日到,春来江上几人还?","卢纶《长安春望》"] +["今夜月明人尽望,不知秋思落谁家。","王建《十五夜望月寄杜郎中》"] +["琵琶弦上说相思。当时明月在,曾照彩云归。","晏几道《临江仙·梦后楼台高锁》"] +["相思似海深,旧事如天远。","乐婉《卜算子·答施》"] +["惟将终夜常开眼,报答平生未展眉。","元稹《遣悲怀三首·其三》"] +["山随平野尽,江入大荒流。","李白《渡荆门送别》"] +["平林漠漠烟如织,寒山一带伤心碧。","李白《菩萨蛮·平林漠漠烟如织》"] +["千里黄云白日曛,北风吹雁雪纷纷。","高适《别董大二首》"] +["云淡风轻近午天,傍花随柳过前川。","程颢《春日偶成·云淡风轻近午天》"] +["白发悲花落,青云羡鸟飞。","岑参《寄左省杜拾遗》"] +["东风不与周郎便,铜雀春深锁二乔。","杜牧《赤壁》"] +["最是一年春好处,绝胜烟柳满皇都。","韩愈《早春呈水部张十八员外 / 初春小雨 / 早春》"] +["银烛秋光冷画屏,轻罗小扇扑流萤。","杜牧《秋夕》"] +["花似伊。柳似伊。花柳青春人别离。","欧阳修《长相思·花似伊》"] +["谁教岁岁红莲夜,两处沉吟各自知。","姜夔《鹧鸪天·元夕有所梦》"] +["知有儿童挑促织,夜深篱落一灯明。","叶绍翁《夜书所见》"] +["高楼目尽欲黄昏,梧桐叶上萧萧雨。","晏殊《踏莎行·碧海无波》"] +["一月不读书,耳目失精爽。","萧抡谓《读书有所见作》"] +["此去经年,应是良辰好景虚设。","柳永《雨霖铃·寒蝉凄切》"] +["沧浪之水清兮,可以濯我缨;","佚名《沧浪歌》"] +["相寻梦里路,飞雨落花中。","晏几道《临江仙·斗草阶前初见》"] +["垂死病中惊坐起,暗风吹雨入寒窗。","元稹《闻乐天授江州司马》"] +["江涵秋影雁初飞,与客携壶上翠微。","杜牧《九日齐山登高》"] +["西风多少恨,吹不散眉弯。","纳兰性德《临江仙·寒柳》"] +["暮云收尽溢清寒。银汉无声转玉盘。","苏轼《阳关曲 / 中秋月》"] +["云横秦岭家何在?雪拥蓝关马不前。","韩愈《左迁至蓝关示侄孙湘》"] +["寒雨连天夜入吴,平明送客楚山孤。","王昌龄《芙蓉楼送辛渐二首》"] +["绿遍山原白满川,子规声里雨如烟。","翁卷《乡村四月》"] +["风烟俱净,天山共色。","吴均《与朱元思书》"] +["冰,水为之,而寒于水。","荀子《劝学》"] +["洛阳城里春光好,洛阳才子他乡老。","韦庄《菩萨蛮》"] +["日暮乡关何处是?烟波江上使人愁。","崔颢《黄鹤楼 / 登黄鹤楼》"] +["柳条折尽花飞尽,借问行人归不归?","佚名《送别诗》"] +["杜鹃再拜忧天泪,精卫无穷填海心。","黄遵宪《赠梁任父母同年 / 题梁任父同年》"] +["冠盖满京华,斯人独憔悴。","杜甫《梦李白二首·其二》"] +["晓看红湿处,花重锦官城。","杜甫《春夜喜雨》"] +["移舟去。未成新句。一砚梨花雨。","周晋《点绛唇·访牟存叟南漪钓隐》"] +["梅子金黄杏子肥,麦花雪白菜花稀。","范成大《四时田园杂兴·其二》"] +["愁肠已断无由醉,酒未到,先成泪。","范仲淹《御街行·秋日怀旧》"] +["浮云游子意,落日故人情。","李白《送友人》"] +["故人入我梦,明我长相忆。","杜甫《梦李白二首·其一》"] +["唯愿当歌对酒时,月光长照金樽里。","李白《把酒问月·故人贾淳令予问之》"] +["梧桐树,三更雨,不道离情正苦。","温庭筠《更漏子·玉炉香》"] +["欲渡黄河冰塞川,将登太行雪满山。","李白《行路难·其一》"] +["姑苏城外寒山寺,夜半钟声到客船。","张继《枫桥夜泊 / 夜泊枫江》"] +["郁孤台下清江水,中间多少行人泪?","辛弃疾《菩萨蛮·书江西造口壁》"] +["低头弄莲子,莲子清如水。","佚名《西洲曲》"] +["三万里河东入海,五千仞岳上摩天。","陆游《秋夜将晓出篱门迎凉有感》"] +["闲云潭影日悠悠,物换星移几度秋。","王勃《滕王阁序》"] +["秋风萧瑟天气凉,草木摇落露为霜,群燕辞归鹄南翔。","曹丕《燕歌行二首·其一》"] +["月黑见渔灯,孤光一点萤。","查慎行《舟夜书所见》"] +["洛阳城东桃李花,飞来飞去落谁家?","刘希夷《代悲白头翁 / 白头吟 / 有所思》"] +["二十余年如一梦,此身虽在堪惊。","陈与义《临江仙·夜登小阁忆洛中旧游》"] +["彩线轻缠红玉臂,小符斜挂绿云鬟。佳人相见一千年。","苏轼《浣溪沙·端午》"] +["桃花春色暖先开,明媚谁人不看来。","周朴《桃花》"] +["一朵芙蕖,开过尚盈盈。","苏轼《江神子·江景》"] +["明月楼高休独倚。酒入愁肠,化作相思泪。","范仲淹《苏幕遮·怀旧》"] +["一夜雨声凉到梦,万荷叶上送秋来。","陈文述《夏日杂诗》"] +["东武望余杭,云海天涯两渺茫。","苏轼《南乡子·和杨元素时移守密州》"] +["沙上并禽池上暝。云破月来花弄影。","张先《天仙子·水调数声持酒听》"] +["细雨鱼儿出,微风燕子斜。","杜甫《水槛遣心二首》"] +["今我来思,雨雪霏霏。","佚名《采薇》"] +["离多最是,东西流水,终解两相逢。","晏几道《少年游·离多最是》"] +["深知身在情长在,怅望江头江水声。","李商隐《暮秋独游曲江》"] +["江流天地外,山色有无中。","王维《汉江临泛 / 汉江临眺》"] +["山不厌高,海不厌深。","曹操《短歌行》"] +["两岸猿声啼不住,轻舟已过万重山。","李白《早发白帝城 / 白帝下江陵》"] +["若有知音见采,不辞遍唱阳春。","晏殊《山亭柳·赠歌者》"] +["凄凉别后两应同,最是不胜清怨月明中。","纳兰性德《虞美人·曲阑深处重相见》"] +["杨花榆荚无才思,惟解漫天作雪飞。","韩愈《晚春》"] +["谁谓伤心画不成,画人心逐世人情。","韦庄《金陵图》"] +["三千丈清愁鬓发,五十年春梦繁华。","乔吉《折桂令·客窗清明》"] +["渭城朝雨浥轻尘,客舍青青柳色新。","王维《渭城曲 / 送元二使安西》"] +["读书破万卷,下笔如有神。","杜甫《奉赠韦左丞丈二十二韵》"] +["十年无梦得还家,独立青峰野水涯。","谢枋得《武夷山中》"] +["千里江山寒色远,芦花深处泊孤舟,笛在月明楼。","李煜《望江南·闲梦远》"] +["王师北定中原日,家祭无忘告乃翁。","陆游《示儿》"] +["悲欢离合总无情。一任阶前、点滴到天明。","蒋捷《虞美人·听雨》"] +["扬子江头杨柳春,杨花愁杀渡江人。","郑谷《淮上与友人别》"] +["两句三年得,一吟双泪流。","贾岛《题诗后》"] +["浊酒一杯家万里,燕然未勒归无计。","范仲淹《渔家傲·秋思》"] +["遥知兄弟登高处,遍插茱萸少一人。","王维《九月九日忆山东兄弟 / 九月九忆山东兄弟》"] +["相思本是无凭语,莫向花笺费泪行。","晏几道《鹧鸪天·醉拍春衫惜旧香》"] +["青海长云暗雪山,孤城遥望玉门关。","王昌龄《从军行七首·其四》"] +["天门中断楚江开,碧水东流至此回。","李白《望天门山》"] +["仍怜故乡水,万里送行舟。","李白《渡荆门送别》"] +["叶上初阳干宿雨、水面清圆,一一风荷举。","周邦彦《苏幕遮·燎沉香》"] +["梅子留酸软齿牙,芭蕉分绿与窗纱。","杨万里《闲居初夏午睡起》"] +["庭树不知人去尽,春来还发旧时花。","岑参《山房春事二首》"] +["共眠一舸听秋雨,小簟轻衾各自寒。","朱彝尊《桂殿秋·思往事》"] +["兴尽晚回舟,误入藕花深处。","李清照《如梦令·常记溪亭日暮》"] +["清谈可以饱,梦想接无由。","韩愈《洞庭湖阻风赠张十一署·时自阳山徙掾江陵》"] +["牧童归去横牛背,短笛无腔信口吹。","雷震《村晚》"] +["蓬头稚子学垂纶,侧坐莓苔草映身。","胡令能《小儿垂钓》"] +["牧童骑黄牛,歌声振林樾。","袁枚《所见》"] +["残星几点雁横塞,长笛一声人倚楼。","赵嘏《长安晚秋 / 秋望 / 秋夕》"] +["月出惊山鸟,时鸣春涧中。","王维《鸟鸣涧》"] +["纸屏石枕竹方床,手倦抛书午梦长。","蔡确《夏日登车盖亭》"] +["登高壮观天地间,大江茫茫去不还。","李白《庐山谣寄卢侍御虚舟》"] +["落木千山天远大,澄江一道月分明。","黄庭坚《登快阁》"] +["别来半岁音书绝,一寸离肠千万结。","韦庄《应天长·别来半岁音书绝》"] +["江南春尽离肠断,苹满汀洲人未归。","寇准《江南春·波渺渺》"] +["当时轻别意中人,山长水远知何处。","晏殊《踏莎行·碧海无波》"] +["梦回人远许多愁,只在梨花风雨处。","辛弃疾《玉楼春·风前欲劝春光住》"] +["楼船夜雪瓜洲渡,铁马秋风大散关。","陆游《书愤五首·其一》"] +["千古江山,英雄无觅,孙仲谋处。","辛弃疾《永遇乐·京口北固亭怀古》"] +["酒债寻常行处有,人生七十古来稀。","杜甫《曲江二首》"] +["笑尽一杯酒,杀人都市中。","李白《结客少年场行》"] +["宫女如花满春殿,只今惟有鹧鸪飞。","李白《越中览古》"] +["都道无人愁似我,今夜雪,有梅花,似我愁。","蒋捷《梅花引·荆溪阻雪》"] +["芳树无人花自落,春山一路鸟空啼。","李华《春行即兴》"] +["日暮酒醒人已远,满天风雨下西楼。","许浑《谢亭送别》"] +["春来秋去相思在,秋去春来信息稀。","鱼玄机《闺怨》"] +["知君用心如日月,事夫誓拟同生死。","张籍《节妇吟·寄东平李司空师道》"] +["斑竹枝,斑竹枝,泪痕点点寄相思。","刘禹锡《潇湘神·斑竹枝》"] +["凉月如眉挂柳湾,越中山色镜中看。","戴叔伦《兰溪棹歌》"] +["昨夜雨疏风骤,浓睡不消残酒。","李清照《如梦令·昨夜雨疏风骤》"] +["纤云弄巧,飞星传恨,银汉迢迢暗度。","秦观《鹊桥仙·纤云弄巧》"] +["涉江采芙蓉,兰泽多芳草。","佚名《涉江采芙蓉》"] +["杨柳阴阴细雨晴,残花落尽见流莺。","武元衡《春兴》"] +["朱弦已为佳人绝,青眼聊因美酒横。","黄庭坚《登快阁》"] +["人家见生男女好,不知男女催人老。","王建《短歌行》"] +["令公桃李满天下,何用堂前更种花。","白居易《奉和令公绿野堂种花》"] +["寻常一样窗前月,才有梅花便不同。","杜耒《寒夜》"] +["殷勤昨夜三更雨,又得浮生一日凉。","苏轼《鹧鸪天·林断山明竹隐墙》"] +["冰骨清寒瘦一枝。玉人初上木兰时。","吴文英《浣溪沙·题李中斋舟中梅屏》"] +["待到重阳日,还来就菊花。","孟浩然《过故人庄》"] +["小娃撑小艇,偷采白莲回。","白居易《池上》"] +["十年种木,一年种谷,都付儿童。","元好问《人月圆·重冈已隔红尘断》"] +["穷且益坚,不坠青云之志。","王勃《滕王阁序》"] +["一曲新词酒一杯,去年天气旧亭台。","晏殊《浣溪沙·一曲新词酒一杯》"] +["晚年唯好静,万事不关心。","王维《酬张少府》"] +["帘外雨潺潺,春意阑珊。","李煜《浪淘沙令·帘外雨潺潺》"] +["未觉池塘春草梦,阶前梧叶已秋声。","朱熹《偶成》"] +["春山烟欲收,天淡星稀小。","牛希济《生查子·春山烟欲收》"] +["有三秋桂子,十里荷花。","柳永《望海潮·东南形胜》"] +["尘世难逢开口笑,菊花须插满头归。","杜牧《九日齐山登高》"] +["泰山不要欺毫末,颜子无心羡老彭。","白居易《放言五首·其五》"] +["雪纷纷,掩重门,不由人不断魂,瘦损江梅韵。","关汉卿《大德歌·冬》"] +["好风凭借力,送我上青云。","曹雪芹《临江仙·柳絮》"] +["客路青山外,行舟绿水前。","王湾《次北固山下》"] +["空山不见人,但闻人语响。","王维《鹿柴》"] +["青山遮不住,毕竟东流去。","辛弃疾《菩萨蛮·书江西造口壁》"] +["故不登高山,不知天之高也;不临深溪,不知地之厚也;","荀子《劝学》"] +["相思一夜情多少,地角天涯未是长。","张仲素《燕子楼诗三首》"] +["夜夜相思更漏残,伤心明月凭阑干,想君思我锦衾寒。","韦庄《浣溪沙·夜夜相思更漏残》"] +["四海皆兄弟,谁为行路人。","佚名《旧题苏武诗 / 别诗四首·其一》"] +["待浮花、浪蕊都尽,伴君幽独。","苏轼《贺新郎·夏景》"] +["共看明月应垂泪,一夜乡心五处同。","白居易《望月有感》"] +["东篱把酒黄昏后,有暗香盈袖。","李清照《醉花阴·薄雾浓云愁永昼》"] +["江雨霏霏江草齐,六朝如梦鸟空啼。","韦庄《台城》"] +["梦断香消四十年,沈园柳老不吹绵。","陆游《沈园二首》"] +["历览前贤国与家,成由勤俭破由奢。","李商隐《咏史二首·其二》"] +["富贵必从勤苦得,男儿须读五车书。","杜甫《柏学士茅屋》"] +["鸡声茅店月,人迹板桥霜。","温庭筠《商山早行》"] +["遥知湖上一樽酒,能忆天涯万里人。","欧阳修《春日西湖寄谢法曹歌》"] +["对酒卷帘邀明月,风露透窗纱。","苏轼《少年游·润州作》"] +["柳絮风轻,梨花雨细。","谢逸《踏莎行·柳絮风轻》"] +["关关雎鸠,在河之洲。窈窕淑女,君子好逑。","佚名《关雎》"] +["独坐幽篁里,弹琴复长啸。","王维《竹里馆》"] +["不知细叶谁裁出,二月春风似剪刀。","贺知章《咏柳 / 柳枝词》"] +["移舟泊烟渚,日暮客愁新。","孟浩然《宿建德江》"] +["何日归家洗客袍?银字笙调,心字香烧。","蒋捷《一剪梅·舟过吴江》"] +["君自故乡来,应知故乡事。","王维《杂诗三首·其二》"] +["孤帆远影碧空尽,唯见长江天际流。","李白《黄鹤楼送孟浩然之广陵》"] +["四月十七,正是去年今日,别君时。","韦庄《女冠子·四月十七》"] +["孤山寺北贾亭西,水面初平云脚低。","白居易《钱塘湖春行》"] +["寒雨连江夜入吴,平明送客楚山孤。","王昌龄《芙蓉楼送辛渐》"] +["万壑树参天,千山响杜鹃。","王维《送梓州李使君》"] +["招魂楚些何嗟及,山鬼暗啼风雨。","元好问《摸鱼儿·雁丘词 / 迈陂塘》"] +["流水便随春远,行云终与谁同。","晏几道《临江仙·斗草阶前初见》"] +["雨里鸡鸣一两家,竹溪村路板桥斜。","王建《雨过山村》"] +["月下飞天镜,云生结海楼。","李白《渡荆门送别》"] +["黑云压城城欲摧,甲光向日金鳞开。","李贺《雁门太守行》"] +["野火烧不尽,春风吹又生。","白居易《草 / 赋得古原草送别》"] +["不积小流,无以成江海。","荀子《劝学》"] +["兰叶春葳蕤,桂华秋皎洁。","张九龄《感遇十二首·其一》"] +["莫笑农家腊酒浑,丰年留客足鸡豚。","陆游《游山西村》"] +["新丰美酒斗十千,咸阳游侠多少年。","王维《少年行四首》"] +["假金方用真金镀,若是真金不镀金。","李绅《答章孝标》"] +["童孙未解供耕织,也傍桑阴学种瓜。","范成大《夏日田园杂兴·其七》"] +["独上江楼思渺然,月光如水水如天。","赵嘏《江楼旧感》"] +["青女素娥俱耐冷,月中霜里斗婵娟。","李商隐《霜月》"] +["月黑雁飞高,单于夜遁逃。","卢纶《和张仆射塞下曲·其三》"] +["深林人不知,明月来相照。","王维《竹里馆》"] +["捐躯赴国难,视死忽如归!","曹植《白马篇》"] +["少年易学老难成,一寸光阴不可轻。","朱熹《偶成》"] +["今夜偏知春气暖,虫声新透绿窗纱。","刘方平《夜月 / 月夜》"] +["一畦春韭绿,十里稻花香。","曹雪芹《菱荇鹅儿水》"] +["绿杨芳草长亭路。年少抛人容易去。","晏殊《玉楼春·春恨》"] +["满载一船秋色,平铺十里湖光。","张孝祥《西江月·阻风山峰下》"] +["东风袅袅泛崇光,香雾空蒙月转廊。","苏轼《海棠·东风袅袅泛崇光》"] +["春水满四泽,夏云多奇峰。","陶渊明《四时》"] +["明日隔山岳,世事两茫茫。","杜甫《赠卫八处士》"] +["过尽千帆皆不是,斜晖脉脉水悠悠。","温庭筠《望江南·梳洗罢》"] +["不论平地与山尖,无限风光尽被占。","罗隐《蜂》"] +["忆昔花间相见后,只凭纤手,暗抛红豆。","欧阳炯《贺明朝·忆昔花间相见后》"] +["怅恨不逢如意酒。寻思难值有情人。","晏几道《浣溪沙·闲弄筝弦懒系裙》"] +["狂风吹我心,西挂咸阳树。","李白《金乡送韦八之西京》"] +["不知何处吹芦管,一夜征人尽望乡。","李益《夜上受降城闻笛》"] +["世事茫茫难自料,春愁黯黯独成眠。","韦应物《寄李儋元锡》"] +["羌管悠悠霜满地,人不寐,将军白发征夫泪。","范仲淹《渔家傲·秋思》"] +["庭院深深深几许,杨柳堆烟,帘幕无重数。","欧阳修《蝶恋花·庭院深深深几许》"] +["笑杀陶渊明,不饮杯中酒。","李白《嘲王历阳不肯饮酒》"] +["胜败兵家事不期,包羞忍耻是男儿。","杜牧《题乌江亭》"] +["飘飘何所似,天地一沙鸥。","杜甫《旅夜书怀》"] +["揉蓝衫子杏黄裙,独倚玉阑无语点檀唇。","秦观《南歌子·香墨弯弯画》"] +["白雪却嫌春色晚,故穿庭树作飞花。","韩愈《春雪》"] +["留连戏蝶时时舞,自在娇莺恰恰啼。","杜甫《江畔独步寻花·其六》"] +["瀚海阑干百丈冰,愁云惨淡万里凝。","岑参《白雪歌送武判官归京》"] +["槛菊愁烟兰泣露。罗幕轻寒,燕子双飞去。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["这次第,怎一个愁字了得!","李清照《声声慢·寻寻觅觅》"] +["纸灰飞作白蝴蝶,泪血染成红杜鹃。","高翥《清明日对酒》"] +["未收天子河湟地,不拟回头望故乡。","令狐楚《少年行四首·其三》"] +["山回路转不见君,雪上空留马行处。","岑参《白雪歌送武判官归京》"] +["天与短因缘,聚散常容易。","晏几道《生查子·狂花顷刻香》"] +["雁引愁心去,山衔好月来。","李白《与夏十二登岳阳楼》"] +["黄师塔前江水东,春光懒困倚微风。","杜甫《江畔独步寻花·其五》"] +["蜀江水碧蜀山青,圣主朝朝暮暮情。","白居易《长恨歌》"] +["一枝红艳露凝香,云雨巫山枉断肠。","李白《清平调·其二》"] +["白发渔樵江渚上,惯看秋月春风。","杨慎《临江仙·滚滚长江东逝水》"] +["河海不择细流,故能就其深;","李斯《谏逐客书》"] +["竹外一枝斜,想佳人,天寒日暮。","曹组《蓦山溪·梅》"] +["解落三秋叶,能开二月花。","李峤《风》"] +["荷叶罗裙一色裁,芙蓉向脸两边开。","王昌龄《采莲曲》"] +["欲穷千里目,更上一层楼。","王之涣《登鹳雀楼》"] +["摇落深知宋玉悲,风流儒雅亦吾师。","杜甫《咏怀古迹五首·其二》"] +["双星何事今宵会,遗我庭前月一钩。","德容《七夕二首·其一》"] +["常记溪亭日暮,沉醉不知归路。","李清照《如梦令·常记溪亭日暮》"] +["白锦无纹香烂漫,玉树琼葩堆雪。","丘处机《无俗念·灵虚宫梨花词》"] +["孤村落日残霞,轻烟老树寒鸦,一点飞鸿影下。","白朴《天净沙·秋》"] +["长安一片月,万户捣衣声。","李白《子夜吴歌·秋歌》"] +["横看成岭侧成峰,远近高低各不同。","苏轼《题西林壁》"] +["青春须早为,岂能长少年。","孟郊《劝学》"] +["想乌衣年少,芝兰秀发,戈戟云横。","叶梦得《八声甘州·寿阳楼八公山作》"] +["更无柳絮因风起,惟有葵花向日倾。","司马光《客中初夏》"] +["幸遇三杯酒好,况逢一朵花新。","朱敦儒《西江月·世事短如春梦》"] +["笛中闻折柳,春色未曾看。","李白《塞下曲六首·其一》"] +["春日宴,绿酒一杯歌一遍。","冯延巳《长命女·春日宴》"] +["多少天涯未归客,尽借篱落看秋风。","唐寅《菊花》"] +["青箬笠,绿蓑衣,斜风细雨不须归。","张志和《渔歌子·西塞山前白鹭飞》"] +["明月别枝惊鹊,清风半夜鸣蝉。","辛弃疾《西江月·夜行黄沙道中》"] +["此身合是诗人未?细雨骑驴入剑门。","陆游《剑门道中遇微雨》"] +["不知近水花先发,疑是经冬雪未销。","张谓《早梅》"] +["水急客舟疾,山花拂面香。","李白《秋浦歌十七首》"] +["红豆不堪看,满眼相思泪。","牛希济《生查子·新月曲如眉》"] +["别后不知君远近。触目凄凉多少闷。","欧阳修《玉楼春·别后不知君远近》"] +["最是仓皇辞庙日,教坊犹奏别离歌,垂泪对宫娥。","李煜《破阵子·四十年来家国》"] +["惨惨柴门风雪夜,此时有子不如无。","黄景仁《别老母》"] +["长记别伊时,和泪出门相送。","李存勖《如梦令·曾宴桃源深洞》"] +["佳节清明桃李笑,野田荒冢只生愁。","黄庭坚《清明》"] +["此花此叶常相映,翠减红衰愁杀人。","李商隐《赠荷花》"] +["扣舷独啸,不知今夕何夕。","张孝祥《念奴娇·过洞庭》"] +["火树银花合,星桥铁锁开。","苏味道《正月十五夜》"] +["忆昔午桥桥上饮,坐中多是豪英。","陈与义《临江仙·夜登小阁忆洛中旧游》"] +["绿酒初尝人易醉。一枕小窗浓睡。","晏殊《清平乐·金风细细》"] +["疾风知劲草,板荡识诚臣。","李世民《赐萧瑀》"] +["一年三百六十日,多是横戈马上行。","戚继光《马上作》"] +["十里青山远,潮平路带沙。数声啼鸟怨年华。","仲殊《南柯子·十里青山远》"] +["大鹏一日同风起,扶摇直上九万里。","李白《上李邕》"] +["为君持酒劝斜阳,且向花间留晚照。","宋祁《玉楼春·春景》"] +["三更月。中庭恰照梨花雪。","贺铸《子夜歌·三更月》"] +["古木阴中系短篷,杖藜扶我过桥东。","志南《绝句》"] +["乡村四月闲人少,才了蚕桑又插田。","翁卷《乡村四月》"] +["待何年归去,谈笑各争雄。","顾太清《高山流水·次夫子清风阁落成韵》"] +["片云天共远,永夜月同孤。","杜甫《江汉》"] +["惊回千里梦,已三更。起来独自绕阶行。","岳飞《小重山·昨夜寒蛩不住鸣》"] +["十年花骨东风泪,几点螺香素壁尘。","史达祖《鹧鸪天·搭柳栏干倚伫频》"] +["入则无法家拂士,出则无敌国外患者,国恒亡。","孟子及其弟子《生于忧患,死于安乐》"] +["自别后遥山隐隐,更那堪远水粼粼。","王实甫《十二月过尧民歌·别情》"] +["西北望乡何处是,东南见月几回圆。","白居易《八月十五日夜湓亭望月》"] +["车遥遥兮马洋洋,追思君兮不可忘。","傅玄《车遥遥篇》"] +["离愁渐远渐无穷,迢迢不断如春水。","欧阳修《踏莎行·候馆梅残》"] +["五月天山雪,无花只有寒。","李白《塞下曲六首·其一》"] +["积土成山,风雨兴焉;积水成渊,蛟龙生焉;","荀子《劝学》"] +["风暖鸟声碎,日高花影重。","杜荀鹤《春宫怨》"] +["春宵苦短日高起,从此君王不早朝。","白居易《长恨歌》"] +["章台柳,章台柳,昔日青青今在否?","韩翃《章台柳·寄柳氏》"] +["柳如眉,云似发,鲛绡雾縠笼香雪。","魏承班《渔歌子·柳如眉》"] +["最是人间留不住,朱颜辞镜花辞树。","王国维《蝶恋花·阅尽天涯离别苦》"] +["颠狂柳絮随风去,轻薄桃花逐水流。","杜甫《绝句漫兴九首·其五》"] +["白日不到处,青春恰自来。","袁枚《苔》"] +["当年万里觅封侯。匹马戍梁州。","陆游《诉衷情·当年万里觅封侯》"] +["故国三千里,深宫二十年。","张祜《宫词·故国三千里》"] +["问君何能尔?心远地自偏。","陶渊明《饮酒·其五》"] +["芳菲歇去何须恨,夏木阴阴正可人。","秦观《三月晦日偶题》"] +["云尽月如练,水凉风似秋。","欧阳詹《荆南夏夜水楼怀昭丘直上人云梦李莘》"] +["细雨湿衣看不见,闲花落地听无声。","刘长卿《别严士元》"] +["半亩方塘一鉴开,天光云影共徘徊。","朱熹《活水亭观书有感二首·其一》"] +["音尘绝,西风残照,汉家陵阙。","李白《忆秦娥·箫声咽》"] +["骊山语罢清宵半,泪雨霖铃终不怨。","纳兰性德《木兰词·拟古决绝词柬友》"] +["别君去兮何时还?且放白鹿青崖间。须行即骑访名山。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["君家何处住,妾住在横塘。","崔颢《长干行·君家何处住》"] +["淮南皓月冷千山,冥冥归去无人管。","姜夔《踏莎行·自沔东来》"] +["人归落雁后,思发在花前。","薛道衡《人日思归》"] +["猿啼客散暮江头,人自伤心水自流。","刘长卿《重送裴郎中贬吉州》"] +["英雄一去豪华尽,惟有青山似洛中。","许浑《金陵怀古》"] +["白头宫女在,闲坐说玄宗。","元稹《行宫》"] +["愁云淡淡雨潇潇。暮暮复朝朝。","石孝友《眼儿媚·愁云淡淡雨潇潇》"] +["年年负却花期!过春时,只合安排愁绪送春归。","张惠言《相见欢·年年负却花期》"] +["一片花飞减却春,风飘万点正愁人。","杜甫《曲江二首》"] +["孤雁不饮啄,飞鸣声念群。","杜甫《孤雁 / 后飞雁》"] +["独怜幽草涧边生,上有黄鹂深树鸣。","韦应物《滁州西涧》"] +["还似旧时游上苑,车如流水马如龙。","李煜《忆江南·多少恨》"] +["我见青山多妩媚,料青山见我应如是。","辛弃疾《贺新郎·甚矣吾衰矣》"] +["断送一生憔悴,只销几个黄昏。","赵令畤《清平乐·春风依旧》"] +["春风春雨花经眼,江北江南水拍天。","黄庭坚《次元明韵寄子由》"] +["万里无人收白骨,家家城下招魂葬。","张籍《征妇怨》"] +["唯有相思似春色,江南江北送君归。","王维《送沈子归江东 / 送沈子福之江东》"] +["过眼年华,动人幽意,相逢几番春换。","王沂孙《法曲献仙音·聚景亭梅次草窗韵》"] +["西窗下,风摇翠竹,疑是故人来。","秦观《满庭芳·碧水惊秋》"] +["不才明主弃,多病故人疏。","孟浩然《岁暮归南山 / 归故园作 / 归终南山》"] +["楼头残梦五更钟,花底离情三月雨。","晏殊《玉楼春·春恨》"] +["独在异乡为异客,每逢佳节倍思亲。","王维《九月九日忆山东兄弟 / 九月九忆山东兄弟》"] +["不知何岁月,得与尔同归?","韦承庆《南中咏雁诗》"] +["逢郎欲语低头笑,碧玉搔头落水中。","白居易《采莲曲》"] +["请君试问东流水,别意与之谁短长。","李白《金陵酒肆留别》"] +["古人今人若流水,共看明月皆如此。","李白《把酒问月·故人贾淳令予问之》"] +["绿树村边合,青山郭外斜。","孟浩然《过故人庄》"] +["未能抛得杭州去,一半勾留是此湖。","白居易《春题湖上》"] +["湘江两岸花木深,美人不见愁人心。","卢仝《有所思》"] +["谁怜一片影,相失万重云?","杜甫《孤雁 / 后飞雁》"] +["渭北春天树,江东日暮云。","杜甫《春日忆李白》"] +["莺初解语,最是一年春好处。","苏轼《减字木兰花·莺初解语》"] +["三五年时三五月,可怜杯酒不曾消。","黄景仁《绮怀》"] +["一叶渔船两小童,收篙停棹坐船中。","杨万里《舟过安仁》"] +["叶落当归根,云沉久必起。","钱嶫《悯黎咏》"] +["可怜九月初三夜,露似真珠月似弓。","白居易《暮江吟》"] +["参横斗转欲三更,苦雨终风也解晴。","苏轼《六月二十日夜渡海》"] +["淡淡梅花香欲染,丝丝柳带露初干。","曹雪芹《香菱咏月·其二》"] +["落叶聚还散,寒鸦栖复惊。","李白《三五七言 / 秋风词》"] +["竹影和诗瘦,梅花入梦香。","王庭筠《绝句·竹影和诗瘦》"] +["惟有绿荷红菡萏,卷舒开合任天真。","李商隐《赠荷花》"] +["可惜一溪风月,莫教踏碎琼瑶。","苏轼《西江月·顷在黄州》"] +["三军可夺帅也,匹夫不可夺志也。","佚名《论语十二章》"] +["归志宁无五亩园,读书本意在元元。","陆游《读书》"] +["朝真暮伪何人辨,古往今来底事无。","白居易《放言五首·其一》"] +["人生如寄,何事辛苦怨斜晖。","朱熹《水调歌头·隐括杜牧之齐山诗》"] +["子规夜半犹啼血,不信东风唤不回。","王令《送春》"] +["荷风送香气,竹露滴清响。","孟浩然《夏日南亭怀辛大》"] +["月明船笛参差起,风定池莲自在香。","秦观《纳凉》"] +["小扇引微凉,悠悠夏日长。","顾太清《菩萨蛮·端午日咏盆中菊》"] +["冉冉秋光留不住,满阶红叶暮。","李煜《谢新恩·冉冉秋光留不住》"] +["云散月明谁点缀?天容海色本澄清。","苏轼《六月二十日夜渡海》"] +["细雨斜风作晓寒。淡烟疏柳媚晴滩。","苏轼《浣溪沙·元丰七年十二月二十四日从泗州刘倩叔游南山》"] +["天苍苍,野茫茫。风吹草低见牛羊。","佚名《敕勒歌》"] +["终南阴岭秀,积雪浮云端。","祖咏《终南望余雪》"] +["砌下落梅如雪乱,拂了一身还满。","李煜《清平乐·别来春半》"] +["大雪压青松,青松挺且直。","陈毅《青松》"] +["砧杵敲残深巷月,井梧摇落故园秋。","陆游《秋思》"] +["江头未是风波恶,别有人间行路难!","辛弃疾《鹧鸪天·送人》"] +["半卷红旗临易水,霜重鼓寒声不起。","李贺《雁门太守行》"] +["杨柳青青江水平,闻郎江上踏歌声。","刘禹锡《竹枝词二首·其一》"] +["遥望洞庭山水色,白银盘里一青螺。","刘禹锡《望洞庭》"] +["欸乃一声山水绿。回看天际下中流,岩上无心云相逐。","柳宗元《渔翁》"] +["只在此山中,云深不知处。","贾岛《寻隐者不遇 / 孙革访羊尊师诗》"] +["亭亭山上松,瑟瑟谷中风。","刘桢《赠从弟》"] +["闺中少妇不知愁,春日凝妆上翠楼。","王昌龄《闺怨》"] +["小轩独坐相思处,情绪好无聊。","石孝友《眼儿媚·愁云淡淡雨潇潇》"] +["一寸丹心图报国,两行清泪为思亲。","于谦《立春日感怀》"] +["阳关万里道,不见一人归。","庾信《重别周尚书》"] +["自是浮生无可说。人间第一耽离别。","王国维《蝶恋花·满地霜华浓似雪》"] +["故人江海别,几度隔山川。","司空曙《云阳馆与韩绅宿别》"] +["秋风吹到江村,正黄昏,寂寞梧桐夜雨不开门。","顾彩《相见欢·秋风吹到江村》"] +["独出前门望野田,月明荞麦花如雪。","白居易《村夜》"] +["操吴戈兮被犀甲,车错毂兮短兵接。","屈原《国殇》"] +["狂风落尽深红色,绿叶成阴子满枝。","杜牧《怅诗》"] +["菡萏香销翠叶残,西风愁起绿波间。","李璟《摊破浣溪沙·菡萏香销翠叶残》"] +["故园东望路漫漫,双袖龙钟泪不干。","岑参《逢入京使》"] +["知我者,谓我心忧;不知我者,谓我何求。","佚名《黍离》"] +["梨花院落溶溶月,柳絮池塘淡淡风。","晏殊《无题·油壁香车不再逢》"] +["粉堕百花洲,香残燕子楼。","曹雪芹《唐多令·柳絮》"] +["书当快意读易尽,客有可人期不来。","陈师道《绝句·书当快意读易尽》"] +["叹十年心事,休休莫莫。","吴潜《满江红·豫章滕王阁》"] +["鱼书经岁绝,烛泪流残月。","徐灿《菩萨蛮·秋闺》"] +["人间有味是清欢。","苏轼《浣溪沙·细雨斜风作晓寒》"] +["老去悲秋强自宽,兴来今日尽君欢。","杜甫《九日蓝田崔氏庄》"] +["柔情似水,佳期如梦,忍顾鹊桥归路。","秦观《鹊桥仙·纤云弄巧》"] +["王孙莫把比蓬蒿,九日枝枝近鬓毛。","郑谷《菊》"] +["玉容寂寞泪阑干,梨花一枝春带雨。","白居易《长恨歌》"] +["今日斗酒会,明旦沟水头。","卓文君《白头吟》"] +["只恐双溪舴艋舟,载不动许多愁。","李清照《武陵春·春晚》"] +["不信比来长下泪,开箱验取石榴裙。","武则天《如意娘》"] +["无处不伤心,轻尘在玉琴。","纳兰性德《菩萨蛮·萧萧几叶风兼雨》"] +["风急桃花也似愁,点点飞红雨。","如晦《楚天遥过清江引·有意送春归》"] +["细雨梦回鸡塞远,小楼吹彻玉笙寒。","李璟《摊破浣溪沙·菡萏香销翠叶残》"] +["欲为圣明除弊事,肯将衰朽惜残年。","韩愈《左迁至蓝关示侄孙湘》"] +["胡未灭,鬓先秋。泪空流。","陆游《诉衷情·当年万里觅封侯》"] +["蜡烛有心还惜别,替人垂泪到天明。","杜牧《赠别》"] +["不似秋光,只与离人照断肠。","苏轼《减字木兰花·春月》"] +["卧龙跃马终黄土,人事音书漫寂寥。","杜甫《阁夜》"] +["春思乱,芳心碎。","惠洪《千秋岁·半身屏外》"] +["君不见管鲍贫时交,此道今人弃如土。","杜甫《贫交行》"] +["百啭千声随意移,山花红紫树高低。","欧阳修《画眉鸟》"] +["山际见来烟,竹中窥落日。","吴均《山中杂诗》"] +["唯有长江水,无语东流。","柳永《八声甘州·对潇潇暮雨洒江天》"] +["穿花蛱蝶深深见,点水蜻蜓款款飞。","杜甫《曲江二首》"] +["一水护田将绿绕,两山排闼送青来。","王安石《书湖阴先生壁》"] +["西北望长安,可怜无数山。","辛弃疾《菩萨蛮·书江西造口壁》"] +["飞流直下三千尺,疑是银河落九天。","李白《望庐山瀑布》"] +["青山横北郭,白水绕东城。","李白《送友人》"] +["种豆南山下,草盛豆苗稀。","陶渊明《归园田居·其三》"] +["泉眼无声惜细流,树阴照水爱晴柔。","杨万里《小池》"] +["烟霏霏。雪霏霏。雪向梅花枝上堆。","吴淑姬《长相思令·烟霏霏》"] +["玉碗冰寒滴露华,粉融香雪透轻纱。","晏殊《浣溪沙·玉碗冰寒滴露华》"] +["潮平两岸阔,风正一帆悬。","王湾《次北固山下》"] +["飞花两岸照船红,百里榆堤半日风。","陈与义《襄邑道中》"] +["西风吹老洞庭波,一夜湘君白发多。","唐温如《题龙阳县青草湖》"] +["风絮飘残已化萍,泥莲刚倩藕丝萦。","纳兰性德《山花子·风絮飘残已化萍》"] +["黄河之水天上来,奔流到海不复回。","李白《将进酒》"] +["寒蝉凄切,对长亭晚,骤雨初歇。","柳永《雨霖铃·寒蝉凄切》"] +["远书归梦两悠悠,只有空床敌素秋。","李商隐《端居》"] +["深秋帘幕千家雨,落日楼台一笛风。","杜牧《题宣州开元寺水阁阁下宛溪夹溪居人》"] +["人烟寒橘柚,秋色老梧桐。","李白《秋登宣城谢脁北楼》"] +["一霎荷塘过雨,明朝便是秋声。","项鸿祚《清平乐·池上纳凉》"] +["年年陌上生秋草,日日楼中到夕阳。","晏几道《鹧鸪天·醉拍春衫惜旧香》"] +["漠漠秋云起,稍稍夜寒生。","白居易《微雨夜行》"] +["水流花谢两无情,送尽东风过楚城。","崔涂《春夕》"] +["莫嫌举世无知己,未有庸人不忌才。","查慎行《三闾祠》"] +["童子解吟长恨曲,胡儿能唱琵琶篇。","李忱《吊白居易》"] +["何夜无月?何处无竹柏?但少闲人如吾两人者耳。","苏轼《记承天寺夜游 / 记承天夜游》"] +["依旧桃花面,频低柳叶眉。","韦庄《女冠子·昨夜夜半》"] +["戚姬髡发入舂市,万古共悲辛。","李白《中山孺子妾歌》"] +["山城过雨百花尽,榕叶满庭莺乱啼。","柳宗元《柳州二月榕叶落尽偶题》"] +["北风卷地白草折,胡天八月即飞雪。","岑参《白雪歌送武判官归京》"] +["小舟从此逝,江海寄余生。","苏轼《临江仙·夜饮东坡醒复醉》"] +["回首向来萧瑟处,归去,也无风雨也无晴。","苏轼《定风波·莫听穿林打叶声》"] +["北方有佳人,绝世而独立。","李延年《李延年歌》"] +["姑苏城外一茅屋,万树梅花月满天。","唐寅《把酒对月歌》"] +["山远翠眉长。高处凄凉。菊花清瘦杜秋娘。","吴文英《浪淘沙·九日从吴见山觅酒》"] +["苦恨年年压金线,为他人作嫁衣裳。","秦韬玉《贫女》"] +["昔别君未婚,儿女忽成行。","杜甫《赠卫八处士》"] +["松下问童子,言师采药去。","贾岛《寻隐者不遇 / 孙革访羊尊师诗》"] +["壮志饥餐胡虏肉,笑谈渴饮匈奴血。","岳飞《满江红·写怀》"] +["别有幽愁暗恨生,此时无声胜有声。","白居易《琵琶行 / 琵琶引》"] +["行人莫问当年事,故国东来渭水流。","许浑《咸阳城东楼 / 咸阳城西楼晚眺 / 西门》"] +["枕上十年事,江南二老忧,都到心头。","徐再思《水仙子·夜雨》"] +["载酒买花年少事,浑不似,旧心情。","卢祖皋《江城子·画楼帘暮卷新晴》"] +["夜雨剪春韭,新炊间黄粱。","杜甫《赠卫八处士》"] +["春风尔来为阿谁,蝴蝶忽然满芳草。","李白《山人劝酒》"] +["树阴满地日当午,梦觉流莺时一声。","苏舜钦《夏意》"] +["晴日暖风生麦气,绿阴幽草胜花时。","王安石《初夏即事》"] +["江上春山远,山下暮云长。","葛长庚《水调歌头·江上春山远》"] +["常恐秋风早,飘零君不知。","卢照邻《曲池荷》"] +["饮马渡秋水,水寒风似刀。","王昌龄《塞下曲四首》"] +["冰簟银床梦不成,碧天如水夜云轻。","温庭筠《瑶瑟怨》"] +["群芳过后西湖好,狼籍残红。","欧阳修《采桑子·群芳过后西湖好》"] +["人生有情泪沾臆,江水江花岂终极!","杜甫《哀江头》"] +["微微风簇浪,散作满河星。","查慎行《舟夜书所见》"] +["休说鲈鱼堪脍,尽西风,季鹰归未?","辛弃疾《水龙吟·登建康赏心亭》"] +["遥知独听灯前雨,转忆同看雪后山。","纳兰性德《于中好·送梁汾南还为题小影》"] +["人生如梦,一尊还酹江月。","苏轼《念奴娇·赤壁怀古》"] +["燕子飞时,绿水人家绕。","苏轼《蝶恋花·春景》"] +["江晚正愁余,山深闻鹧鸪。","辛弃疾《菩萨蛮·书江西造口壁》"] +["小山重叠金明灭,鬓云欲度香腮雪。","温庭筠《菩萨蛮·小山重叠金明灭》"] +["漠漠轻寒上小楼。晓阴无赖似穷秋。淡烟流水画屏幽。","秦观《浣溪沙·漠漠轻寒上小楼》"] +["妇人依倚子与夫,同居贫贱心亦舒。","张籍《征妇怨》"] +["君安游兮西入秦,愿为影兮随君身。","傅玄《车遥遥篇》"] +["燕山雪花大如席,片片吹落轩辕台。","李白《北风行》"] +["不用思量今古,俯仰昔人非。","苏轼《八声甘州·寄参寥子》"] +["不管烟波与风雨,载将离恨过江南。","郑文宝《柳枝词》"] +["开轩面场圃,把酒话桑麻。","孟浩然《过故人庄》"] +["国亡身殒今何有,只留离骚在世间。","张耒《和端午》"] +["我歌月徘徊,我舞影零乱。","李白《月下独酌四首·其一》"] +["谁知万里客,怀古正踌蹰。","陈子昂《岘山怀古》"] +["草树知春不久归,百般红紫斗芳菲。","韩愈《晚春》"] +["饮酒莫辞醉,醉多适不愁。","高适《淇上送韦司仓往滑台》"] +["不知何日始工愁。记取那回花下一低头。","王国维《虞美人·弄梅骑竹嬉游日》"] +["形影忽不见,翩翩伤我心。","曹植《杂诗六首·其一》"] +["忍泪佯低面,含羞半敛眉。","韦庄《女冠子·四月十七》"] +["落絮无声春堕泪,行云有影月含羞。","吴文英《浣溪沙·门隔花深梦旧游》"] +["春如旧。人空瘦。泪痕红浥鲛绡透。","陆游《钗头凤·红酥手》"] +["不惜千金买宝刀,貂裘换酒也堪豪。","秋瑾《对酒·不惜千金买宝刀》"] +["半天凉月色,一笛酒人心。","袁枚《夜过借园见主人坐月下吹笛》"] +["风雨梨花寒食过,几家坟上子孙来?","高启《送陈秀才还沙上省墓》"] +["昼出耘田夜绩麻,村庄儿女各当家。","范成大《夏日田园杂兴·其七》"] +["人间梦隔西风,算天上、年华一瞬。","吴文英《惜秋华·七夕》"] +["人学始知道,不学非自然。","孟郊《劝学》"] +["一蓑烟雨任平生。","苏轼《定风波·莫听穿林打叶声》"] +["知否,知否?应是绿肥红瘦。","李清照《如梦令·昨夜雨疏风骤》"] +["醉里挑灯看剑,梦回吹角连营。","辛弃疾《破阵子·为陈同甫赋壮词以寄之》"] +["人似秋鸿来有信,事如春梦了无痕。","苏轼《正月二十日与潘郭二生出郊寻春忽记去年是日同至女王城作诗乃和前韵》"] +["坑灰未冷山东乱,刘项原来不读书。","章碣《焚书坑》"] +["登鸾车,侍轩辕,遨游青天中,其乐不可言。","李白《飞龙引二首·其一》"] +["孰知不向边庭苦,纵死犹闻侠骨香。","王维《少年行四首》"] +["伏波惟愿裹尸还,定远何须生入关。","李益《塞下曲》"] +["南北山头多墓田,清明祭扫各纷然。","高翥《清明日对酒》"] +["鸟飞反故乡兮,狐死必首丘。","屈原《九章》"] +["人生有酒须当醉,一滴何曾到九泉。","高翥《清明日对酒》"] +["无由持一碗,寄与爱茶人。","白居易《山泉煎茶有怀》"] +["琴里知闻唯渌水,茶中故旧是蒙山。","白居易《琴茶》"] +["欲黄昏。雨打梨花深闭门。","李重元《忆王孙·春词》"] +["惊起却回头,有恨无人省。","苏轼《卜算子·黄州定慧院寓居作》"] +["芭蕉不展丁香结,同向春风各自愁。","李商隐《代赠二首·其一》"] +["相顾无言,惟有泪千行。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["剑外忽传收蓟北,初闻涕泪满衣裳。","杜甫《闻官军收河南河北》"] +["遗民泪尽胡尘里,南望王师又一年。","陆游《秋夜将晓出篱门迎凉有感》"] +["金陵津渡小山楼,一宿行人自可愁。","张祜《题金陵渡》"] +["一场愁梦酒醒时,斜阳却照深深院。","晏殊《踏莎行·小径红稀》"] +["一枝红杏出墙头,墙外行人正独愁。","吴融《途中见杏花》"] +["寄语东阳沽酒市,拼一醉,而今乐事他年泪。","朱服《渔家傲·小雨纤纤风细细》"] +["把酒送春春不语。黄昏却下潇潇雨。","朱淑真《蝶恋花·送春》"] +["低花树映小妆楼,春入眉心两点愁。","白居易《春词》"] +["我当二十不得意,一心愁谢如枯兰。","李贺《开愁歌》"] +["浊酒不销忧国泪,救时应仗出群才。","秋瑾《黄海舟中日人索句并见日俄战争地图》"] +["雨暗残灯棋散后,酒醒孤枕雁来初。","杜牧《齐安郡晚秋》"] +["古路无行客,寒山独见君。","刘长卿《碧涧别墅喜皇甫侍御相访》"] +["故人相望若为情。别愁深夜雨,孤影小窗灯。","陈克《临江仙·四海十年兵不解》"] +["挥手自兹去,萧萧班马鸣。","李白《送友人》"] +["当路谁相假,知音世所稀。","孟浩然《留别王侍御维 / 留别王维》"] +["行尽江南,不与离人遇。","晏几道《蝶恋花·梦入江南烟水路》"] +["可怜今夕月,向何处、去悠悠。","辛弃疾《木兰花慢·中秋饮酒将旦客谓前人诗词有赋待月无送月者因用天问体赋》"] +["寂寂竟何待,朝朝空自归。","孟浩然《留别王侍御维 / 留别王维》"] +["离别一何久,七度过中秋。","苏辙《水调歌头·徐州中秋》"] +["不见又思量,见了还依旧。","李之仪《谢池春·残寒销尽》"] +["写不成书,只寄得、相思一点。","张炎《解连环·孤雁》"] +["同穴窅冥何所望,他生缘会更难期。","元稹《遣悲怀三首·其三》"] +["残月出门时,美人和泪辞。","韦庄《菩萨蛮·红楼别夜堪惆怅》"] +["妾身悔作商人妇,妾命当逢薄幸夫。","徐再思《阳春曲·闺怨》"] +["一语不入意,从君万曲梁尘飞。","李白《夜坐吟》"] +["若得山花插满头,莫问奴归处。","严蕊《卜算子·不是爱风尘》"] +["白日登山望烽火,黄昏饮马傍交河。","李颀《古从军行》"] +["楚天千里清秋,水随天去秋无际。","辛弃疾《水龙吟·登建康赏心亭》"] +["峰峦如聚,波涛如怒,山河表里潼关路。","张养浩《山坡羊·潼关怀古》"] +["昔闻洞庭水,今上岳阳楼。","杜甫《登岳阳楼》"] +["三山半落青天外,二水中分白鹭洲。","李白《登金陵凤凰台》"] +["若非群玉山头见,会向瑶台月下逢。","李白《清平调·其一》"] +["料峭春风吹酒醒,微冷,山头斜照却相迎。","苏轼《定风波·三月七日》"] +["山光悦鸟性,潭影空人心。","常建《题破山寺后禅院》"] +["蓬山此去无多路,青鸟殷勤为探看。","李商隐《无题·相见时难别亦难》"] +["巴山楚水凄凉地,二十三年弃置身。","刘禹锡《酬乐天扬州初逢席上见赠》"] +["高楼送客不能醉,寂寂寒江明月心。","王昌龄《芙蓉楼送辛渐二首》"] +["水晶帘外娟娟月,梨花枝上层层雪。","杨基《菩萨蛮·水晶帘外娟娟月》"] +["风住尘香花已尽,日晚倦梳头。","李清照《武陵春·春晚》"] +["春悄悄,夜迢迢。碧云天共楚宫遥。","晏几道《鹧鸪天·小令尊前见玉箫》"] +["暖雨晴风初破冻,柳眼梅腮,已觉春心动。","李清照《蝶恋花·暖雨晴风初破冻》"] +["怀君属秋夜,散步咏凉天。","韦应物《秋夜寄邱员外 / 秋夜寄丘二十二员外》"] +["络纬秋啼金井阑,微霜凄凄簟色寒。","李白《长相思·其一》"] +["丁丁漏水夜何长,漫漫轻云露月光。","张仲素《秋夜曲》"] +["金井梧桐秋叶黄,珠帘不卷夜来霜。","王昌龄《长信怨》"] +["红叶晚萧萧,长亭酒一瓢。","许浑《秋日赴阙题潼关驿楼 / 行次潼关逢魏扶东归》"] +["午梦扁舟花底。香满两湖烟水。","杨万里《昭君怨·咏荷上雨》"] +["未信此情难系绊,杨花犹有东风管。","苏轼《蝶恋花·蝶懒莺慵春过半》"] +["律回岁晚冰霜少,春到人间草木知。","张栻《立春偶成》"] +["深居俯夹城,春去夏犹清。","李商隐《晚晴》"] +["彼此当年少,莫负好时光。","李隆基《好时光·宝髻偏宜宫样》"] +["人恒过然后能改,困于心衡于虑而后作,征于色发于声而后喻。","孟子及其弟子《生于忧患,死于安乐》"] +["季子正年少,匹马黑貂裘。","辛弃疾《水调歌头·舟次扬州和人韵》"] +["借问酒家何处有?牧童遥指杏花村。","杜牧《清明》"] +["啼时惊妾梦,不得到辽西。","金昌绪《春怨 / 伊州歌》"] +["巧笑东邻女伴,采桑径里逢迎。","晏殊《破阵子·春景》"] +["团扇,团扇,美人病来遮面。","王建《宫中调笑·团扇》"] +["翠叶藏莺,朱帘隔燕。","晏殊《踏莎行·小径红稀》"] +["丛菊两开他日泪,孤舟一系故园心。","杜甫《秋兴八首·其一》"] +["桃未芳菲杏未红,冲寒先喜笑东风。","曹雪芹《咏红梅花得“红”字》"] +["雪虐风饕愈凛然,花中气节最高坚。","陆游《落梅》"] +["江天一色无纤尘,皎皎空中孤月轮。","张若虚《春江花月夜》"] +["水国蒹葭夜有霜,月寒山色共苍苍。","薛涛《送友人》"] +["忆对中秋丹桂丛。花在杯中。月在杯中。","辛弃疾《一剪梅·中秋元月》"] +["明月如霜,好风如水,清景无限。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["酒困路长惟欲睡,日高人渴漫思茶。","苏轼《浣溪沙·簌簌衣巾落枣花》"] +["人有悲欢离合,月有阴晴圆缺,此事古难全。","苏轼《水调歌头·丙辰中秋》"] +["小时不识月,呼作白玉盘。","李白《古朗月行》"] +["静夜沉沉,浮光霭霭,冷浸溶溶月。","丘处机《无俗念·灵虚宫梨花词》"] +["白云千里万里,明月前溪后溪。","刘长卿《谪仙怨·晴川落日初低》"] +["桃花一簇开无主,可爱深红爱浅红。","杜甫《江畔独步寻花·其五》"] +["河畔青芜堤上柳,为问新愁,何事年年有?","冯延巳《鹊踏枝·谁道闲情抛弃久》"] +["遍身罗绮者,不是养蚕人。","张俞《蚕妇》"] +["素娥惟与月,青女不饶霜。","李商隐《十一月中旬至扶风界见梅花》"] +["月明星稀,乌鹊南飞。绕树三匝,何枝可依?","曹操《短歌行》"] +["大儿锄豆溪东,中儿正织鸡笼。","辛弃疾《清平乐·村居》"] +["哀哀父母,生我劳瘁。","佚名《蓼莪》"] +["人生百年有几,念良辰美景,休放虚过。","元好问《骤雨打新荷》"] +["庭轩寂寞近清明,残花中酒,又是去年病。","张先《青门引·春思》"] +["可堪孤馆闭春寒,杜鹃声里斜阳暮。","秦观《踏莎行·郴州旅舍》"] +["笙歌散尽游人去,始觉春空。","欧阳修《采桑子·群芳过后西湖好》"] +["最爱湖东行不足,绿杨阴里白沙堤。","白居易《钱塘湖春行》"] +["长安白日照春空,绿杨结烟垂袅风。","李白《阳春歌》"] +["风蒲猎猎小池塘。过雨荷花满院香。","李重元《忆王孙·夏词》"] +["别院深深夏席清,石榴开遍透帘明。","苏舜钦《夏意》"] +["湖光秋月两相和,潭面无风镜未磨。","刘禹锡《望洞庭》"] +["长风万里送秋雁,对此可以酣高楼。","李白《宣州谢脁楼饯别校书叔云 / 陪侍御叔华登楼歌》"] +["荒城临古渡,落日满秋山。","王维《归嵩山作》"] +["满园花菊郁金黄,中有孤丛色似霜。","白居易《重阳席上赋白菊》"] +["云无心以出岫,鸟倦飞而知还。","陶渊明《归去来兮辞·并序》"] +["浮云蔽白日,游子不顾返。","佚名《行行重行行》"] +["泠泠七弦上,静听松风寒。","刘长卿《听弹琴》"] +["锦江春色来天地,玉垒浮云变古今。","杜甫《登楼》"] +["岱宗夫如何?齐鲁青未了。","杜甫《望岳》"] +["梅雪争春未肯降,骚人搁笔费评章。","卢梅坡《雪梅·其一》"] +["天将暮,雪乱舞,半梅花半飘柳絮。","马致远《寿阳曲·江天暮雪》"] +["草枯鹰眼疾,雪尽马蹄轻。","王维《观猎》"] +["中岁颇好道,晚家南山陲。","王维《终南别业 / 初至山中 / 入山寄城中故人》"] +["平芜尽处是春山,行人更在春山外。","欧阳修《踏莎行·候馆梅残》"] +["寒山转苍翠,秋水日潺湲。","王维《辋川闲居赠裴秀才迪》"] +["淮水东边旧时月,夜深还过女墙来。","刘禹锡《金陵五题·石头城》"] +["最是繁丝摇落后,转教人忆春山。","纳兰性德《临江仙·寒柳》"] +["浮天水送无穷树,带雨云埋一半山。","辛弃疾《鹧鸪天·送人》"] +["山寺月中寻桂子,郡亭枕上看潮头。","白居易《忆江南·江南忆》"] +["重见金英人未见。相思一夜天涯远。","晏几道《蝶恋花·黄菊开时伤聚散》"] +["闲坐悲君亦自悲,百年多是几多时。","元稹《遣悲怀三首·其三》"] +["雁字无多,写得相思几许。","周密《扫花游·九日怀归》"] +["明岁秋风知再会,暂时分手莫相思。","曹雪芹《残菊》"] +["故乡今夜思千里,霜鬓明朝又一年。","高适《除夜作》"] +["搴帷拜母河梁去,白发愁看泪眼枯。","黄景仁《别老母》"] +["谁知江上酒,还与故人倾。","周紫芝《临江仙·送光州曾使君》"] +["丈夫贫贱应未足,今日相逢无酒钱。","高适《别董大二首》"] +["鸿雁在云鱼在水。惆怅此情难寄。","晏殊《清平乐·红笺小字》"] +["杀人亦有限,列国自有疆。","杜甫《前出塞九首·其六》"] +["吴宫花草埋幽径,晋代衣冠成古丘。","李白《登金陵凤凰台》"] +["赏心乐事共谁论?花下销魂,月下销魂。","唐寅《一剪梅·雨打梨花深闭门》"] +["含情欲说宫中事,鹦鹉前头不敢言。","朱庆馀《宫词 / 宫中词》"] +["自春来、惨绿愁红,芳心是事可可。","柳永《定风波·自春来》"] +["一上高城万里愁,蒹葭杨柳似汀洲。","许浑《咸阳城东楼 / 咸阳城西楼晚眺 / 西门》"] +["纱窗日落渐黄昏,金屋无人见泪痕。","刘方平《春怨》"] +["正是江南好风景,落花时节又逢君。","杜甫《江南逢李龟年》"] +["千淘万漉虽辛苦,吹尽狂沙始到金。","刘禹锡《浪淘沙九首》"] +["金樽清酒斗十千,玉盘珍羞直万钱。","李白《行路难·其一》"] +["狎兴生疏,酒徒萧索,不似少年时。","柳永《少年游·长安古道马迟迟》"] +["一杯酒,问何似,身后名?","辛弃疾《水调歌头·壬子三山被召陈端仁给事饮饯席上作》"] +["万里中原烽火北,一尊浊酒戍楼东。","张孝祥《浣溪沙·霜日明霄水蘸空》"] +["嗟余听鼓应官去,走马兰台类转蓬。","李商隐《无题·昨夜星辰昨夜风》"] +["雁声远过潇湘去,十二楼中月自明。","温庭筠《瑶瑟怨》"] +["烟柳画桥,风帘翠幕,参差十万人家。","柳永《望海潮·东南形胜》"] +["汉兵已略地,四方楚歌声。","虞姬《和项王歌》"] +["陌上深深,依旧年时辙。","王国维《蝶恋花·满地霜华浓似雪》"] +["有约不来过夜半,闲敲棋子落灯花。","赵师秀《约客》"] +["流光容易把人抛,红了樱桃,绿了芭蕉。","蒋捷《一剪梅·舟过吴江》"] +["天涯何处无芳草!墙里秋千墙外道。","苏轼《蝶恋花·春景》"] +["粗缯大布裹生涯,腹有诗书气自华。","苏轼《和董传留别》"] +["来岁花前。又是今年忆去年。","吕本中《减字木兰花·去年今夜》"] +["流年莫虚掷,华发不相容。","方干《送从兄郜 / 韦郜 / 途中别孙璐》"] +["当年得意如芳草。日日春风好。","辛弃疾《虞美人·赋虞美人草》"] +["弱冠弄柔翰,卓荦观群书。","左思《咏史八首·其一》"] +["男儿西北有神州,莫滴水西桥畔泪。","刘克庄《玉楼春·戏林推》"] +["马作的卢飞快,弓如霹雳弦惊。","辛弃疾《破阵子·为陈同甫赋壮词以寄之》"] +["艰难苦恨繁霜鬓,潦倒新停浊酒杯。","杜甫《登高》"] +["高情已逐晓云空。不与梨花同梦。","苏轼《西江月·梅花》"] +["竹杖芒鞋轻胜马,谁怕?","苏轼《定风波·莫听穿林打叶声》"] +["门外无人问落花,绿阴冉冉遍天涯。","曹豳《春暮·门外无人问落花》"] +["而今识尽愁滋味,欲说还休。","辛弃疾《丑奴儿·书博山道中壁》"] +["一声何满子,双泪落君前。","张祜《宫词·故国三千里》"] +["日长篱落无人过,惟有蜻蜓蛱蝶飞。","范成大《四时田园杂兴·其二》"] +["晓镜但愁云鬓改,夜吟应觉月光寒。","李商隐《无题·相见时难别亦难》"] +["胡雁哀鸣夜夜飞,胡儿眼泪双双落。","李颀《古从军行》"] +["昨风一吹无人会,今夜清光似往年。","白居易《八月十五日夜湓亭望月》"] +["人老去西风白发,蝶愁来明日黄花。","张可久《折桂令·九日》"] +["翠蛾轻敛意沉吟,沾襟,无人知此心。","孙光宪《河传·花落》"] +["影孤怜夜永。永夜怜孤影。","苏轼《菩萨蛮·回文秋闺怨》"] +["人去空流水,花飞半掩门。","秦观《南歌子·香墨弯弯画》"] +["君看六幅南朝事,老木寒云满故城。","韦庄《金陵图》"] +["恰似春风相欺得,夜来吹折数枝花。","杜甫《绝句漫兴九首·其二》"] +["春色恼人眠不得,月移花影上栏杆。","王安石《春夜》"] +["春路雨添花,花动一山春色。","秦观《好事近·梦中作》"] +["寄语洛城风日道,明年春色倍还人。","杜审言《春日京中有怀》"] +["花开不并百花丛,独立疏篱趣未穷。","郑思肖《画菊》"] +["不知来岁牡丹时,再相逢何处。","叶清臣《贺圣朝·留别》"] +["君到姑苏见,人家尽枕河。","杜荀鹤《送人游吴》"] +["午醉醒来愁未醒。送春春去几时回。","张先《天仙子·水调数声持酒听》"] +["明日相思莫上楼,楼上多风雨。","游次公《卜算子·风雨送人来》"] +["望阙云遮眼,思乡雨滴心。","白居易《阴雨》"] +["城上高楼接大荒,海天愁思正茫茫。","柳宗元《登柳州城楼寄漳汀封连四州》"] +["四面边声连角起,千嶂里,长烟落日孤城闭。","范仲淹《渔家傲·秋思》"] +["后夜相思,尘随马去,月逐舟行。","刘过《柳梢青·送卢梅坡》"] +["晨起动征铎,客行悲故乡。","温庭筠《商山早行》"] +["寄相思,寒雨灯窗,芙蓉旧院。","吴文英《宴清都·秋感》"] +["天长路远魂飞苦,梦魂不到关山难。","李白《长相思·其一》"] +["旋开旋落旋成空,白发多情人更惜。","司空图《酒泉子·买得杏花》"] +["夜长争得薄情知,春初早被相思染。","姜夔《踏莎行·自沔东来》"] +["三春白雪归青冢,万里黄河绕黑山。","柳中庸《征人怨 / 征怨》"] +["莫遣只轮归海窟,仍留一箭射天山。","李益《塞下曲》"] +["云收雨过波添,楼高水冷瓜甜,绿树阴垂画檐。","白朴《天净沙·夏》"] +["京口瓜洲一水间,钟山只隔数重山。","王安石《泊船瓜洲》"] +["万里赴戎机,关山度若飞。","佚名《木兰诗 / 木兰辞》"] +["溪深难受雪,山冻不流云。","洪升《雪望》"] +["长江悲已滞,万里念将归。","王勃《山中》"] +["杨柳东风树,青青夹御河。","王之涣《送别》"] +["溪深古雪在,石断寒泉流。","李白《寻高凤石门山中元丹丘》"] +["伫倚危楼风细细。望极春愁,黯黯生天际。","柳永《蝶恋花·伫倚危楼风细细》"] +["大风起兮云飞扬。威加海内兮归故乡。安得猛士兮守四方!","刘邦《大风歌》"] +["风急天高猿啸哀,渚清沙白鸟飞回。","杜甫《登高》"] +["晚风庭院落梅初。淡云来往月疏疏。","李清照《浣溪沙·髻子伤春慵更梳》"] +["山黛远,月波长,暮云秋影蘸潇湘。","蔡松年《鹧鸪天·赏荷》"] +["梅定妒,菊应羞。画阑开处冠中秋。","李清照《鹧鸪天·桂花》"] +["黄菊枝头生晓寒。人生莫放酒杯干。","黄庭坚《鹧鸪天·坐中有眉山隐客史应之和前韵》"] +["楼倚霜树外,镜天无一毫。","杜牧《长安秋望》"] +["西风乱叶溪桥树。秋在黄花羞涩处。","张榘《青玉案·被檄出郊题陈氏山居》"] +["秋草独寻人去后,寒林空见日斜时。","刘长卿《长沙过贾谊宅》"] +["荷花开后西湖好,载酒来时。","欧阳修《采桑子·荷花开后西湖好》"] +["夜合花开香满庭,夜深微雨醉初醒。","窦叔向《夏夜宿表兄话旧》"] +["报道先生归也,杏花春雨江南。","虞集《风入松·寄柯敬仲》"] +["春风不解禁杨花,濛濛乱扑行人面。","晏殊《踏莎行·小径红稀》"] +["夜雨连明春水生,娇云浓暖弄阴晴。","苏舜钦《初晴游沧浪亭》"] +["夜来南风起,小麦覆陇黄。","白居易《观刈麦》"] +["亦知合被才名折,二十三年折太多。","白居易《醉赠刘二十八使君》"] +["镜中已觉星星误。人不负春春自负。","辛弃疾《玉楼春·风前欲劝春光住》"] +["爱子心无尽,归家喜及辰。","蒋士铨《岁暮到家》"] +["读书不作儒生酸,跃马西入金城关。","谢逸《送董元达》"] +["昨日邻家乞新火,晓窗分与读书灯。","王禹偁《清明》"] +["休言女子非英物,夜夜龙泉壁上鸣。","秋瑾《鹧鸪天·祖国沉沦感不禁》"] +["妾家高楼连苑起,良人执戟明光里。","张籍《节妇吟·寄东平李司空师道》"] +["孤灯不明思欲绝,卷帷望月空长叹。美人如花隔云端!","李白《长相思·其一》"] +["卢家少妇郁金堂,海燕双栖玳瑁梁。","沈佺期《古意呈补阙乔知之 / 古意 / 独不见》"] +["劳歌一曲解行舟,红叶青山水急流。","许浑《谢亭送别》"] +["千里稻花应秀色,五更桐叶最佳音。","曾几《苏秀道中》"] +["世间花叶不相伦,花入金盆叶作尘。","李商隐《赠荷花》"] +["风波不信菱枝弱,月露谁教桂叶香。","李商隐《无题·重帏深下莫愁堂》"] +["水风轻,蘋花渐老,月露冷、梧叶飘黄。","柳永《玉蝴蝶·望处雨收云断》"] +["清江一曲柳千条,二十年前旧板桥。","刘禹锡《杨柳枝 / 柳枝词》"] +["陌上风光浓处。第一寒梅先吐。","李弥逊《十样花·陌上风光浓处》"] +["百亩庭中半是苔,桃花净尽菜花开。","刘禹锡《再游玄都观》"] +["开花必早落,桃李不如松。","李白《箜篌谣》"] +["笙歌散后酒初醒,深院月斜人静。","司马光《西江月·宝髻松松挽就》"] +["家家乞巧望秋月,穿尽红丝几万条。","林杰《乞巧》"] +["边月随弓影,胡霜拂剑花。","李白《塞下曲六首》"] +["千门万户曈曈日,总把新桃换旧符。","王安石《元日》"] +["满眼游丝兼落絮,红杏开时,一霎清明雨。","冯延巳《鹊踏枝·清明》"] +["我本楚狂人,凤歌笑孔丘。","李白《庐山谣寄卢侍御虚舟》"] +["人生不相见,动如参与商。","杜甫《赠卫八处士》"] +["唯有牡丹真国色,花开时节动京城。","刘禹锡《赏牡丹》"] +["冲天香阵透长安,满城尽带黄金甲。","黄巢《不第后赋菊》"] +["柳外轻雷池上雨,雨声滴碎荷声。小楼西角断虹明。","欧阳修《临江仙·柳外轻雷池上雨》"] +["桃花流水窅然去,别有天地非人间。","李白《山中问答》"] +["烟水茫茫,千里斜阳暮。山无数。乱红如雨。","秦观《点绛唇·桃源》"] +["玉楼金阙慵归去,且插梅花醉洛阳。","朱敦儒《鹧鸪天·西都作》"] +["青青河畔草,郁郁园中柳。","佚名《青青河畔草》"] +["掩柴扉,谢他梅竹伴我冷书斋。","沈自晋《玉芙蓉·雨窗小咏》"] +["水晶帘不下,云母屏开,冷浸佳人淡脂粉。","晁补之《洞仙歌·泗州中秋作》"] +["辛苦遭逢起一经,干戈寥落四周星。","文天祥《过零丁洋》"] +["路人借问遥招手,怕得鱼惊不应人。","胡令能《小儿垂钓》"] +["儿童相见不相识,笑问客从何处来。","贺知章《回乡偶书二首·其一》"] +["日出入安穷?时世不与人同。","佚名《日出入》"] +["年年今夜,月华如练,长是人千里。","范仲淹《御街行·秋日怀旧》"] +["若待明朝风雨过,人在天涯!春在天涯。","虞集《南乡一剪梅·招熊少府》"] +["新年都未有芳华,二月初惊见草芽。","韩愈《春雪》"] +["春雪满空来,触处似花开。","赵嘏《喜张沨及第》"] +["长于春梦几多时,散似秋云无觅处。","晏殊《木兰花·燕鸿过后莺归去》"] +["叹息老来交旧尽,睡来谁共午瓯茶。","陆游《幽居初夏》"] +["戍鼓断人行,边秋一雁声。","杜甫《月夜忆舍弟》"] +["秋晚莼鲈江上,夜深儿女灯前。","辛弃疾《木兰花慢·滁州送范倅》"] +["寸寸微云,丝丝残照,有无明灭难消。","贺双卿《凤凰台上忆吹箫·寸寸微云》"] +["白云一片去悠悠,青枫浦上不胜愁。","张若虚《春江花月夜》"] +["浅情终似,行云无定,犹到梦魂中。","晏几道《少年游·离多最是》"] +["风回云断雨初晴,返照湖边暖复明。","白居易《南湖早春》"] +["山桃红花满上头,蜀江春水拍山流。","刘禹锡《竹枝词·山桃红花满上头》"] +["万树寒无色,南枝独有花。","道源《早梅》"] +["前村深雪里,昨夜一枝开。","齐己《早梅》"] +["灵山多秀色,空水共氤氲。","张九龄《湖口望庐山瀑布泉 / 湖口望庐山瀑布水》"] +["春已归来,看美人头上,袅袅春幡。","辛弃疾《汉宫春·立春日》"] +["凝恨对残晖,忆君君不知。","韦庄《菩萨蛮》"] +["思君如陇水,长闻呜咽声。","雍裕之《自君之出矣》"] +["相思难表,梦魂无据,惟有归来是。","欧阳修《青玉案·一年春事都来几》"] +["一夜相思,水边清浅横枝瘦。","陈亮《点绛唇·咏梅月》"] +["相思无因见,怅望凉风前。","李白《折荷有赠》"] +["奴为出来难,教君恣意怜。","李煜《菩萨蛮·花明月暗笼轻雾》"] +["掩妾泪,听君歌。","李白《夜坐吟》"] +["自从一闭风光后,几度飞来不见人。","李益《隋宫燕》"] +["长风几万里,吹度玉门关。","李白《关山月》"] +["文章已满行人耳,一度思卿一怆然。","李忱《吊白居易》"] +["风里落花谁是主?思悠悠。","李璟《摊破浣溪沙·手卷真珠上玉钩》"] +["江春不肯留归客,草色青青送马蹄。","刘长卿《送李判官之润州行营》"] +["青青一树伤心色,曾入几人离恨中。","白居易《青门柳》"] +["暂伴月将影,行乐须及春。","李白《月下独酌四首·其一》"] +["无聊成独卧,弹指韶光过。","纳兰性德《菩萨蛮·新寒中酒敲窗雨》"] +["僵卧孤村不自哀,尚思为国戍轮台。","陆游《十一月四日风雨大作》"] +["轻解罗裳,独上兰舟。","李清照《一剪梅·红藕香残玉簟秋》"] +["而今灯漫挂。不是暗尘明月,那时元夜。","蒋捷《女冠子·元夕》"] +["伤心千古,秦淮一片明月!","萨都剌《念奴娇·登石头城次东坡韵》"] +["蚤是伤春梦雨天,可堪芳草更芊芊。","韦庄《长安清明》"] +["却看妻子愁何在,漫卷诗书喜欲狂。","杜甫《闻官军收河南河北》"] +["在天愿作比翼鸟,在地愿为连理枝。","白居易《长恨歌》"] +["且乐生前一杯酒,何须身后千载名?","李白《行路难三首》"] +["望云惭高鸟,临水愧游鱼。","陶渊明《始作镇军参军经曲阿作》"] +["鸟去鸟来山色里,人歌人哭水声中。","杜牧《题宣州开元寺水阁阁下宛溪夹溪居人》"] +["白鸟一双临水立,见人惊起入芦花。","戴复古《江村晚眺》"] +["征蓬出汉塞,归雁入胡天。","王维《使至塞上》"] +["泥融飞燕子,沙暖睡鸳鸯。","杜甫《绝句二首》"] +["奇文共欣赏,疑义相与析。","陶渊明《移居二首》"] +["天涯海角悲凉地,记得当年全盛时。","赵鼎《鹧鸪天·建康上元作》"] +["夜战桑乾北,秦兵半不归。","许浑《塞下曲》"] +["树绕村庄,水满陂塘。","秦观《行香子·树绕村庄》"] +["四海无闲田,农夫犹饿死。","李绅《古风二首 / 悯农二首》"] +["道狭草木长,夕露沾我衣。","陶渊明《归园田居·其三》"] +["壮岁从戎,曾是气吞残虏。","陆游《谢池春·壮岁从戎》"] +["心逐南云逝,形随北雁来。","江总《于长安归还扬州九月九日行薇山亭赋韵 / 长安九日诗》"] +["恨人间、会少离多,万古千秋今夕。","张埜《夺锦标·七夕》"] +["碧水丹山映杖藜,夕阳犹在小桥西。","沈周《题画》"] +["千门开锁万灯明,正月中旬动地京。","张祜《正月十五夜灯》"] +["夕阳西下,塞雁南飞,渭水东流。","康与之《诉衷情令·长安怀古》"] +["酒已都醒,如何消夜永!","周邦彦《关河令·秋阴时晴渐向暝》"] +["正单衣试酒,恨客里、光阴虚掷。","周邦彦《六丑·落花》"] +["酒盈杯,书满架,名利不将心挂。","李珣《渔歌子·荻花秋》"] +["老人七十仍沽酒,千壶百瓮花门口。","岑参《戏问花门酒家翁》"] +["酒美梅酸,恰称人怀抱。","李清照《蝶恋花·上巳召亲族》"] +["何当共剪西窗烛,却话巴山夜雨时。","李商隐《夜雨寄北》"] +["桐花万里丹山路,雏凤清于老凤声。","李商隐《韩冬郎即席为诗相送一座尽惊他日余方追吟连宵侍坐裴回久之句有老成之风因成二绝寄酬兼呈畏之员外·其一》"] +["寻常百种花齐发,偏摘梨花与白人。","元稹《离思五首》"] +["三杯两盏淡酒,怎敌他、晚来风急?","李清照《声声慢·寻寻觅觅》"] +["归来饱饭黄昏后,不脱蓑衣卧月明。","吕岩《牧童 / 令牧童答钟弱翁》"] +["花明柳暗绕天愁,上尽重城更上楼。","李商隐《夕阳楼》"] +["玉骨那愁瘴雾,冰姿自有仙风。","苏轼《西江月·梅花》"] +["夕阳牛背无人卧,带得寒鸦两两归。","张舜民《村居·水绕陂田竹绕篱》"] +["玉惨花愁出凤城,莲花楼下柳青青。","聂胜琼《鹧鸪天·别情》"] +["曲终人醉。多似浔阳江上泪。","朱敦儒《减字木兰花·刘郎已老》"] +["今宵绝胜无人共,卧看星河尽意明。","陈与义《雨晴·天缺西南江面清》"] +["大树无枝向北风,十年遗恨泣英雄。","高启《吊岳王墓》"] +["燕草如碧丝,秦桑低绿枝。","李白《春思》"] +["春种一粒粟,秋收万颗子。","李绅《古风二首 / 悯农二首》"] +["可怜白骨攒孤冢,尽为将军觅战功。","张蠙《吊万人冢》"] +["空床卧听南窗雨,谁复挑灯夜补衣。","贺铸《半死桐·重过阊门万事非》"] +["但将千岁叶,常奉万年杯。","赵彦昭《奉和元日赐群臣柏叶应制》"] +["多少六朝兴废事,尽入渔樵闲话。","张昪《离亭燕·一带江山如画》"] +["诗界千年靡靡风,兵魂销尽国魂空。","梁启超《读陆放翁集》"] +["一身报国有万死,双鬓向人无再青。","陆游《夜泊水村》"] +["清秋幕府井梧寒,独宿江城蜡炬残。","杜甫《宿府》"] +["旅馆寒灯独不眠,客心何事转凄然。","高适《除夜作》"] +["所志在功名,离别何足叹。","陆龟蒙《别离》"] +["握手西风泪不干,年来多在别离间。","纳兰性德《于中好·送梁汾南还为题小影》"] +["烽火连三月,家书抵万金。","杜甫《春望》"] +["死别已吞声,生别常恻恻。","杜甫《梦李白二首·其一》"] +["隔座送钩春酒暖,分曹射覆蜡灯红。","李商隐《无题·昨夜星辰昨夜风》"] +["扶桑已在渺茫中,家在扶桑东更东。","韦庄《送日本国僧敬龙归》"] +["一看肠一断,好去莫回头。","白居易《南浦别》"] +["又送王孙去,萋萋满别情。","白居易《草 / 赋得古原草送别》"] +["满目飞花万点,回首故人千里,把酒沃愁肠。","葛长庚《水调歌头·江上春山远》"] +["洞庭有归客,潇湘逢故人。","柳恽《江南曲》"] +["洛阳城里见秋风,欲作家书意万重。","张籍《秋思》"] +["相思一夜窗前梦,奈个人、水隔天遮。","王沂孙《高阳台·和周草窗寄越中诸友韵》"] +["想得玉人情,也合思量我。","孙光宪《生查子·窗雨阻佳期》"] +["此地动归念,长年悲倦游。","王籍《入若耶溪》"] +["若为化得身千亿,散上峰头望故乡。","柳宗元《与浩初上人同看山寄京华亲故》"] +["鸿雁来时,无限思量。","晏殊《诉衷情·芙蓉金菊斗馨香》"] +["白头空负雪边春,着意问春春不语。","刘镇《玉楼春·东山探梅》"] +["劝君频入醉乡来,此是无愁无恨处。","晏几道《玉楼春·雕鞍好为莺花住》"] +["若待得君来向此,花前对酒不忍触。","苏轼《贺新郎·夏景》"] +["繁华事散逐香尘,流水无情草自春。","杜牧《金谷园》"] +["南山与秋色,气势两相高。","杜牧《长安秋望》"] +["四月清和雨乍晴,南山当户转分明。","司马光《客中初夏》"] +["绿阴不减来时路,添得黄鹂四五声。","曾几《三衢道中》"] +["明月照积雪,朔风劲且哀。","谢灵运《岁暮》"] +["云山行处合,风雨兴中秋。","高适《送魏八》"] +["江间波浪兼天涌,塞上风云接地阴。","杜甫《秋兴八首·其一》"] +["山无陵,江水为竭。","佚名《上邪》"] +["晨起开门雪满山,雪晴云淡日光寒。","郑燮《山中雪后》"] +["轮台九月风夜吼,一川碎石大如斗,随风满地石乱走。","岑参《走马川行奉送出师西征 / 走马川行奉送封大夫出师西征》"] +["风雨如晦,鸡鸣不已。","佚名《风雨》"] +["桂花浮玉,正月满天街,夜凉如洗。","文征明《念奴娇·中秋对月》"] +["南湖秋水夜无烟,耐可乘流直上天。","李白《游洞庭湖五首·其二》"] +["圆荷浮小叶,细麦落轻花。","杜甫《为农》"] +["楝花飘砌。蔌蔌清香细。","谢逸《千秋岁·咏夏景》"] +["湖上春来似画图,乱峰围绕水平铺。","白居易《春题湖上》"] +["东风吹柳日初长,雨余芳草斜阳。","秦观《画堂春·春情》"] +["亭亭画舸系春潭,直到行人酒半酣。","郑文宝《柳枝词》"] +["双飞燕子几时回?夹岸桃花蘸水开。","徐俯《春游湖》"] +["风雨送春归,飞雪迎春到。","毛泽东《卜算子·咏梅》"] +["况是青春日将暮,桃花乱落如红雨。","李贺《将进酒》"] +["草色青青柳色黄,桃花历乱李花香。","贾至《春思二首·其一》"] +["白云依静渚,春草闭闲门。","刘长卿《寻南溪常山道人隐居 / 寻南溪常道士》"] +["斜风细雨作春寒。对尊前。","朱淑真《江城子·赏春》"] +["莺嘴啄花红溜,燕尾点波绿皱。","秦观《如梦令·春景》"] +["细雨春芜上林苑,颓垣夜月洛阳宫。","陆游《书愤五首·其二》"] +["来是春初,去是春将老。","曾允元《点绛唇·一夜东风》"] +["草萤有耀终非火,荷露虽团岂是珠。","白居易《放言五首·其一》"] +["风吹古木晴天雨,月照平沙夏夜霜。","白居易《江楼夕望招客》"] +["竹深树密虫鸣处,时有微凉不是风。","杨万里《夏夜追凉》"] +["碧海年年,试问取、冰轮为谁圆缺?","纳兰性德《琵琶仙·中秋》"] +["旧游无处不堪寻。无寻处,惟有少年心。","章良能《小重山·柳暗花明春事深》"] +["朽木不可雕也,粪土之墙不可圬也!","佚名《宰予昼寝》"] +["谁言寸草心,报得三春晖。","孟郊《游子吟 / 迎母漂上作》"] +["至今商女,时时犹唱,后庭遗曲。","王安石《桂枝香·登临送目》"] +["笔落惊风雨,诗成泣鬼神。","杜甫《寄李十二白二十韵》"] +["数亩荒园留我住,半瓶浊酒待君温。","苏轼《正月二十日往岐亭郡人潘古郭三人送余于女王城东禅庄院》"] +["易挑锦妇机中字。难得玉人心下事。","刘克庄《玉楼春·戏林推》"] +["过江千尺浪,入竹万竿斜。","李峤《风》"] +["来日绮窗前,寒梅著花未。","王维《杂诗三首 / 杂咏三首》"] +["采莲南塘秋,莲花过人头。","佚名《西洲曲》"] +["水榭风微玉枕凉。牙床角簟藕花香。","苏庠《浣溪沙·书虞元翁书》"] +["闲来无事不从容,睡觉东窗日已红。","程颢《秋日》"] +["浩荡离愁白日斜,吟鞭东指即天涯。","龚自珍《己亥杂诗·其五》"] +["清晨入古寺,初日照高林。","常建《题破山寺后禅院》"] +["星月皎洁,明河在天,四无人声,声在树间。","欧阳修《秋声赋》"] +["夜雪初霁,荠麦弥望。","姜夔《扬州慢·淮左名都》"] +["蒹葭苍苍,白露为霜。","佚名《蒹葭》"] +["以饵取鱼,鱼可杀;以禄取人,人可竭;以家取国,国可拔;以国取天下,天下可毕。","《六韬·文韬·文师》"] +["梦里不知身是客,一晌贪欢。","李煜《浪淘沙令·帘外雨潺潺》"] +["我是清都山水郎,天教分付与疏狂。","朱敦儒《鹧鸪天·西都作》"] +["吹灯窗更明,月照一天雪。","袁枚《十二月十五夜》"] +["残雪压枝犹有桔,冻雷惊笋欲抽芽。","欧阳修《戏答元珍》"] +["闲梦江南梅熟日,夜船吹笛雨萧萧。","皇甫松《梦江南·兰烬落》"] +["锦江近西烟水绿,新雨山头荔枝熟。","张籍《成都曲》"] +["回乐峰前沙似雪,受降城外月如霜。","李益《夜上受降城闻笛》"] +["日暮东风怨啼鸟,落花犹似堕楼人。","杜牧《金谷园》"] +["自是荷花开较晚,孤负东风。","幼卿《浪淘沙·极目楚天空》"] +["香非在蕊,香非在萼,骨中香彻。","晁补之《盐角儿·亳社观梅》"] +["桃之夭夭,其叶蓁蓁。","佚名《桃夭》"] +["孤灯寒照雨,湿竹暗浮烟。","司空曙《云阳馆与韩绅宿别》"] +["绿叶阴浓,遍池亭水阁,偏趁凉多。","元好问《骤雨打新荷》"] +["最是秋风管闲事,红他枫叶白人头。","赵翼《野步》"] +["一字无题外,落叶都愁。","张炎《八声甘州·记玉关踏雪事清游》"] +["低头羞见人,双手结裙带。","毛铉《幼女词·下床着新衣》"] +["佳人彩云里,欲赠隔远天。","李白《折荷有赠》"] +["银鞍照白马,飒沓如流星。","李白《侠客行》"] +["神女生涯原是梦,小姑居处本无郎。","李商隐《无题·重帏深下莫愁堂》"] +["父兮生我,母兮鞠我。","佚名《蓼莪》"] +["今人嗤点流传赋,不觉前贤畏后生。","杜甫《戏为六绝句》"] +["为问山翁何事,坐看流年轻度,拚却鬓双华。","叶梦得《水调歌头·秋色渐将晚》"] +["水满有时观下鹭,草深无处不鸣蛙。","陆游《幽居初夏》"] +["子规啼,不如归,道是春归人未归。","关汉卿《大德歌·春》"] +["茅檐人静,蓬窗灯暗,春晚连江风雨。","陆游《鹊桥仙·夜闻杜鹃》"] +["犹有桃花流水上,无辞竹叶醉尊前。","刘禹锡《忆江南·春去也》"] +["便觉眼前生意满,东风吹水绿参差。","张栻《立春偶成》"] +["故园书动经年绝,华发春唯满镜生。","崔涂《春夕》"] +["一春略无十日晴,处处浮云将雨行。","汪藻《春日·一春略无十日晴》"] +["海棠不惜胭脂色,独立蒙蒙细雨中。","陈与义《春寒》"] +["蒌蒿满地芦芽短,正是河豚欲上时。","苏轼《惠崇春江晚景 / 惠崇春江晓景》"] +["西城杨柳弄春柔。动离忧。泪难收。","秦观《江城子·西城杨柳弄春柔》"] +["怅卧新春白袷衣,白门寥落意多违。","李商隐《春雨》"] +["水际轻烟,沙边微雨。荷花芳草垂杨渡。","刘将孙《踏莎行·闲游》"] +["罗幕轻寒,燕子双飞去。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["秋来相顾尚飘蓬,未就丹砂愧葛洪。","杜甫《赠李白》"] +["秋已尽,日犹长,仲宣怀远更凄凉。","李清照《鹧鸪天·寒日萧萧上琐窗》"] +["看蓬门秋草,年年破巷,疏窗细雨,夜夜孤灯。","郑板桥《沁园春·恨》"] +["秋雨一何碧,山色倚晴空。","方岳《水调歌头·平山堂用东坡韵》"] +["秋气堪悲未必然,轻寒正是可人天。","杨万里《秋凉晚步》"] +["对此怀素心,千里共明月。","德隐《新秋晚眺》"] +["天容水色西湖好,云物俱鲜。","欧阳修《采桑子·天容水色西湖好》"] +["澄江平少岸,幽树晚多花。","杜甫《水槛遣心二首》"] +["落日熔金,暮云合璧,人在何处。","李清照《永遇乐·落日熔金》"] +["举头红日近,回首白云低。","寇准《咏华山》"] +["可有梅花寄一枝?雪来翠羽飞。","吴锡麒《长相思·以书寄西泠诸友即题其后》"] +["江湖多风波,舟楫恐失坠。","杜甫《梦李白二首·其二》"] +["浔阳江头夜送客,枫叶荻花秋瑟瑟。","白居易《琵琶行 / 琵琶引》"] +["行人与我玩幽境,北风切切吹衣冷。","李颀《少室雪晴送王宁》"] +["不辞山路远,踏雪也相过。","张九龄《答陆澧》"] +["夜深知雪重,时闻折竹声。","白居易《夜雪》"] +["岁暮阴阳催短景,天涯霜雪霁寒宵。","杜甫《阁夜》"] +["雪里已知春信至。寒梅点缀琼枝腻。","李清照《渔家傲·雪里已知春信至》"] +["雪月最相宜,梅雪都清绝。","张孝祥《卜算子·雪月最相宜》"] +["飞絮飞花何处是,层冰积雪摧残,疏疏一树五更寒。","纳兰性德《临江仙·寒柳》"] +["此身行作稽山土,犹吊遗踪一泫然。","陆游《沈园二首》"] +["试上超然台上看,半壕春水一城花。","苏轼《望江南·超然台作》"] +["梅子黄时日日晴,小溪泛尽却山行。","曾几《三衢道中》"] +["荷笠带斜阳,青山独归远。","刘长卿《送灵澈上人》"] +["八月湖水平,涵虚混太清。","孟浩然《望洞庭湖赠张丞相 / 临洞庭》"] +["山光忽西落,池月渐东上。","孟浩然《夏日南亭怀辛大》"] +["碧水浩浩云茫茫,美人不来空断肠。","李白《早春寄王汉阳》"] +["无那尘缘容易绝,燕子依然,软踏帘钩说。","纳兰性德《蝶恋花·辛苦最怜天上月》"] +["寂寞深闺,柔肠一寸愁千缕。","李清照《点绛唇·闺思》"] +["佳人独立相思苦。薄袖欺寒脩竹暮。","刘镇《玉楼春·东山探梅》"] +["书卷多情似故人,晨昏忧乐每相亲。","于谦《观书》"] +["他乡共酌金花酒,万里同悲鸿雁天。","卢照邻《九月九日玄武山旅眺》"] +["往事思量一晌空,飞絮无情,依旧烟笼。","夏完淳《一剪梅·咏柳》"] +["邯郸驿里逢冬至,抱膝灯前影伴身。","白居易《邯郸冬至夜思家》"] +["相逢意气为君饮,系马高楼垂柳边。","王维《少年行四首》"] +["故人早晚上高台。赠我江南春色、一枝梅。","舒亶《虞美人·寄公度》"] +["醉别江楼橘柚香,江风引雨入舟凉。","王昌龄《送魏二》"] +["饮散离亭西去,浮生长恨飘蓬。","徐昌图《临江仙·饮散离亭西去》"] +["红楼隔雨相望冷,珠箔飘灯独自归。","李商隐《春雨》"] +["细草微风岸,危樯独夜舟。","杜甫《旅夜书怀》"] +["烈士暮年,壮心不已。","曹操《龟虽寿》"] +["胸中有誓深于海,肯使神州竟陆沉?","郑思肖《二砺》"] +["夜来携手梦同游,晨起盈巾泪莫收。","白居易《梦微之》"] +["念兰堂红烛,心长焰短,向人垂泪。","晏殊《撼庭秋·别来音信千里》"] +["从来幽怨应无数?铁马金戈,青冢黄昏路。","纳兰性德《蝶恋花·出塞》"] +["柳色参差掩画楼,晓莺啼送满宫愁。","司马扎《宫怨 / 闺怨》"] +["花红易衰似郎意,水流无限似侬愁。","刘禹锡《竹枝词·山桃红花满上头》"] +["昨日入城市,归来泪满巾。","张俞《蚕妇》"] +["花径不曾缘客扫,蓬门今始为君开。","杜甫《客至》"] +["天意怜幽草,人间重晚晴。","李商隐《晚晴》"] +["庄生晓梦迷蝴蝶,望帝春心托杜鹃。","李商隐《锦瑟》"] +["不怕风狂雨骤,恰才称,煮酒笺花。","李清照《转调满庭芳·芳草池塘》"] +["雨后寒轻,风前香软,春在梨花。","仲殊《柳梢青·吴中》"] +["日日深杯酒满,朝朝小圃花开。","朱敦儒《西江月·日日深杯酒满》"] +["下马饮君酒,问君何所之?","王维《送别》"] +["酌酒以自宽,举杯断绝歌路难。","鲍照《拟行路难·其四》"] +["穷愁千万端,美酒三百杯。","李白《月下独酌四首》"] +["茶鼎熟,酒卮扬,醉来诗兴狂。","张大烈《阮郎归·立夏》"] +["台榭空蒙烟柳暗,白鸟衔鱼欲舞。","纳兰性德《摸鱼儿·午日雨眺》"] +["半竿落日,两行新雁,一叶扁舟。","贺铸《眼儿媚·萧萧江上荻花秋》"] +["人去秋千闲挂月,马停杨柳倦嘶风。","吴文英《望江南·三月暮》"] +["斜倚画阑娇不语,暗移梅影过红桥,裙带北风飘。","纳兰性德《望江南·咏弦月》"] +["无花无酒过清明,兴味萧然似野僧。","王禹偁《清明》"] +["百草千花寒食路,香车系在谁家树。","冯延巳《鹊踏枝·几日行云何处去》"] +["翻空白鸟时时见,照水红蕖细细香。","苏轼《鹧鸪天·林断山明竹隐墙》"] +["烽火照西京,心中自不平。","杨炯《从军行》"] +["君王游乐万机轻,一曲霓裳四海兵。","李约《过华清宫》"] +["箫鼓追随春社近,衣冠简朴古风存。","陆游《游山西村》"] +["叹江山如故,千村寥落。","岳飞《满江红·登黄鹤楼有感》"] +["传闻一战百神愁,两岸强兵过未休。","曹松《己亥岁二首·僖宗广明元年》"] +["百战沙场碎铁衣,城南已合数重围。","李白《从军行》"] +["等闲老去年华促。只有江梅伴幽独。","孙道绚《滴滴金·梅》"] +["十年旧梦无寻处,几度新春不在家。","吴文英《思佳客·癸卯除夜》"] +["游说万乘苦不早,著鞭跨马涉远道。","李白《南陵别儿童入京》"] +["孤鸿号外野,翔鸟鸣北林。","阮籍《咏怀八十二首·其一》"] +["玉珰缄札何由达,万里云罗一雁飞。","李商隐《春雨》"] +["秦中花鸟已应阑,塞外风沙犹自寒。","王翰《凉州词二首》"] +["佳时倍惜风光别,不为登高。只觉魂销。","纳兰性德《采桑子·九日》"] +["冥冥重泉哭不闻,萧萧暮雨人归去。","白居易《寒食野望吟》"] +["半盏屠苏犹未举,灯前小草写桃符。","陆游《除夜雪》"] +["晓雾将歇,猿鸟乱鸣;","陶弘景《答谢中书书》"] +["矮纸斜行闲作草,晴窗细乳戏分茶。","陆游《临安春雨初霁》"] +["呼儿将出换美酒,与尔同销万古愁。","李白《将进酒》"] +["小酌酒巡销永夜,大开口笑送残年。","白居易《雪夜小饮赠梦得》"] +["残酒忆荆高,燕赵悲歌事未消。","陈维崧《南乡子·邢州道上作》"] +["高山仰止,景行行止。","佚名《车舝》"] +["非淡泊无以明志,非宁静无以致远。","诸葛亮《诫子书》"] +["一枕新愁,残夜花香月满楼。","项鸿祚《减字木兰花·春夜闻隔墙歌吹声》"] +["但见泪痕湿,不知心恨谁。","李白《怨情》"] +["日色已尽花含烟,月明欲素愁不眠。","李白《长相思三首·其二》"] +["重帏深下莫愁堂,卧后清宵细细长。","李商隐《无题·重帏深下莫愁堂》"] +["春去也,飞红万点愁如海。","秦观《千秋岁·水边沙外》"] +["心断新丰酒,销愁斗几千。","李商隐《风雨》"] +["红莲相倚浑如醉,白鸟无言定自愁。","辛弃疾《鹧鸪天·鹅湖归病起作》"] +["惜别伤离方寸乱。忘了临行,酒盏深和浅。","李清照《蝶恋花·泪湿罗衣脂粉满》"] +["欲知方寸,共有几许清愁,芭蕉不展丁香结。","贺铸《石州引·薄雨初寒》"] +["雨横风狂三月暮,门掩黄昏,无计留春住。","欧阳修《蝶恋花·庭院深深深几许》"] +["翰林风月三千首,寄与吴姬忍泪看。","刘著《鹧鸪天·雪照山城玉指寒》"] +["唱罢秋坟愁未歇,春丛认取双栖蝶。","纳兰性德《蝶恋花·辛苦最怜天上月》"] +["淮水悠悠。万顷烟波万顷愁。","淮上女《减字木兰花·淮山隐隐》"] +["念归林叶换,愁坐露华生。","张九龄《西江夜行》"] +["无半点闲愁去处,问三生醉梦何如。","乔吉《折桂令·七夕赠歌者》"] +["故国神游,多情应笑我,早生华发。","苏轼《念奴娇·赤壁怀古》"] +["六朝文物草连空,天淡云闲今古同。","杜牧《题宣州开元寺水阁阁下宛溪夹溪居人》"] +["先天下之忧而忧,后天下之乐而乐","范仲淹《岳阳楼记》"] +["睡起莞然成独笑,数声渔笛在沧浪。","蔡确《夏日登车盖亭》"] +["亲朋无一字,老病有孤舟。","杜甫《登岳阳楼》"] +["万里悲秋常作客,百年多病独登台。","杜甫《登高》"] +["地下千年骨,谁为辅佐臣。","戎昱《咏史 / 和蕃》"] +["池塘生春草,园柳变鸣禽。","谢灵运《登池上楼》"] +["我亦且如常日醉,莫教弦管作离声。","欧阳修《别滁》"] +["望君烟水阔,挥手泪沾巾。","刘长卿《饯别王十一南游》"] +["别离滋味浓于酒。著人瘦。此情不及墙东柳。春色年年如旧。","张耒《秋蕊香·帘幕疏疏风透》"] +["明朝又是孤舟别,愁见河桥酒幔青。","窦叔向《夏夜宿表兄话旧》"] +["别来不寄一行书。寻常相见了,犹道不如初。","晁冲之《临江仙·忆昔西池池上饮》"] +["长安故人问我,道寻常、泥酒只依然。","辛弃疾《木兰花慢·滁州送范倅》"] +["相思谩然自苦,算云烟、过眼总成空。","戴复古《木兰花慢·莺啼啼不尽》"] +["西园何限相思树,辛苦梅花候海棠。","元好问《鹧鸪天·候馆灯昏雨送凉》"] +["山抹微云,天连衰草,画角声断谯门。","秦观《满庭芳·山抹微云》"] +["山高月小,水落石出。","苏轼《后赤壁赋》"] +["惟有楼前流水,应念我、终日凝眸。","李清照《凤凰台上忆吹箫·香冷金猊》"] +["水调数声持酒听。午醉醒来愁未醒。","张先《天仙子·水调数声持酒听》"] +["却下水晶帘,玲珑望秋月。","李白《玉阶怨》"] +["数萼初含雪,孤标画本难。","崔道融《梅花》"] +["黄云万里动风色,白波九道流雪山。","李白《庐山谣寄卢侍御虚舟》"] +["江涵雁影梅花瘦,四无尘、雪飞云起,夜窗如昼。","卢祖皋《贺新郎·挽住风前柳》"] +["江深竹静两三家,多事红花映白花。","杜甫《江畔独步寻花七绝句》"] +["越人语天姥,云霞明灭或可睹。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["白云回望合,青霭入看无。","王维《终南山》"] +["青山霁后云犹在,画出东南四五峰。","郎士元《柏林寺南望》"] +["山中一夜雨,树杪百重泉。","王维《送梓州李使君》"] +["余霞散成绮,澄江静如练。","谢朓《晚登三山还望京邑》"] +["黄河落天走东海,万里写入胸怀间。","李白《赠裴十四》"] +["大漠风尘日色昏,红旗半卷出辕门。","王昌龄《从军行七首》"] +["夜阑风静欲归时,惟有一江明月碧琉璃。","苏轼《虞美人·有美堂赠述古》"] +["鹊飞山月曙,蝉噪野风秋。","上官仪《入朝洛堤步月》"] +["菊花何太苦,遭此两重阳?","李白《九月十日即事》"] +["乐游原上清秋节,咸阳古道音尘绝。","李白《忆秦娥·箫声咽》"] +["荆溪白石出,天寒红叶稀。","王维《山中》"] +["日暮秋烟起,萧萧枫树林。","戴叔伦《过三闾庙》"] +["一轮秋影转金波。飞镜又重磨。","辛弃疾《太常引·建康中秋夜为吕叔潜赋》"] +["桂魄初生秋露微,轻罗已薄未更衣。","王维《秋夜曲》"] +["水天清话,院静人销夏。","项鸿祚《清平乐·池上纳凉》"] +["谷鸟吟晴日,江猿啸晚风。","李白《江夏别宋之悌》"] +["吴酒一杯春竹叶,吴娃双舞醉芙蓉。","白居易《忆江南词三首》"] +["借问此何时?春风语流莺。","李白《春日醉起言志》"] +["布谷飞飞劝早耕,舂锄扑扑趁春睛。","姚鼐《山行·布谷飞飞劝早耕》"] +["风前欲劝春光住。春在城南芳草路。","辛弃疾《玉楼春·风前欲劝春光住》"] +["叹年光过尽,功名未立,书生老去,机会方来。","刘克庄《沁园春·梦孚若》"] +["工欲善其事,必先利其器。","佚名《论语七则》"] +["不鸣则已,一鸣惊人。","司马迁《滑稽列传》"] +["乍见翻疑梦,相悲各问年。","司空曙《云阳馆与韩绅宿别》"] +["青春几何时,黄鸟鸣不歇。","李白《江南春怀》"] +["最喜小儿亡赖,溪头卧剥莲蓬。","辛弃疾《清平乐·村居》"] +["军歌应唱大刀环,誓灭胡奴出玉关。","徐锡麟《出塞》"] +["东飞伯劳西飞燕,黄姑织女时相见。","萧衍《东飞伯劳歌》"] +["北斗七星高,哥舒夜带刀。","西鄙人《哥舒歌》"] +["凭阑半日独无言,依旧竹声新月似当年。","李煜《虞美人·风回小院庭芜绿》"] +["桑之未落,其叶沃若。","佚名《氓》"] +["桃李风前多妩媚,杨柳更温柔。","辛弃疾《武陵春·桃李风前多妩媚》"] +["桃花落。闲池阁。山盟虽在,锦书难托。","陆游《钗头凤·红酥手》"] +["步转回廊,半落梅花婉娩香。","苏轼《减字木兰花·春月》"] +["睡起觉微寒。梅花鬓上残。","李清照《菩萨蛮·风柔日薄春犹早》"] +["荷尽已无擎雨盖,菊残犹有傲霜枝。","苏轼《赠刘景文 / 冬景》"] +["田田初出水,菡萏念娇蕊。","龚翔麟《菩萨蛮·题画》"] +["桃李出深井,花艳惊上春。","李白《中山孺子妾歌》"] +["落日无情最有情,遍催万树暮蝉鸣。","杨万里《初秋行圃》"] +["明月皎皎照我床,星汉西流夜未央。","曹丕《燕歌行二首·其一》"] +["花明月暗笼轻雾,今宵好向郎边去。","李煜《菩萨蛮·花明月暗笼轻雾》"] +["云和积雪苍山晚,烟伴残阳绿树昏。","周朴《春日秦国怀古》"] +["两人对酌山花开,一杯一杯复一杯。","李白《山中与幽人对酌》"] +["今朝有酒今朝醉,明日愁来明日愁。","罗隐《自遣》"] +["人生到处知何似,应似飞鸿踏雪泥。","苏轼《和子由渑池怀旧》"] +["雾失楼台,月迷津渡。","秦观《踏莎行·郴州旅舍》"] +["和羞走,倚门回首,却把青梅嗅。","李清照《点绛唇·蹴罢秋千》"] +["毋意,毋必,毋固,毋我。","《论语·子罕篇》"] +["侯门一入深如海,从此萧郎是路人。","崔郊《赠去婢》"] +["一点浩然气,千里快哉风。","苏轼《水调歌头·黄州快哉亭赠张偓佺》"] +["十步杀一人,千里不留行。","李白《侠客行》"] +["林下春晴风渐和,高崖残雪已无多。","王守仁《春晴》"] +["两岸荔枝红,万家烟雨中。","李师中《菩萨蛮·子规啼破城楼月》"] +["闲立春塘烟淡淡,静眠寒苇雨飕飕。","郑谷《鹭鸶》"] +["杏花时节雨纷纷。山绕孤村。水绕孤村。","刘仙伦《一剪梅·唱到阳关第四声》"] +["灞原风雨定,晚见雁行频。","马戴《灞上秋居》"] +["一春梦雨常飘瓦,尽日灵风不满旗。","李商隐《重过圣女祠》"] +["人悄悄,帘外月胧明。","岳飞《小重山·昨夜寒蛩不住鸣》"] +["朗月清风,浓烟暗雨,天教憔悴度芳姿。","李清照《多丽·咏白菊》"] +["日出东南隅,照我秦氏楼。","佚名《陌上桑》"] +["云移雉尾开宫扇,日绕龙鳞识圣颜。","杜甫《秋兴八首》"] +["野店桃花红粉姿,陌头杨柳绿烟丝。","赵孟頫《东城》"] +["绿竹含新粉,红莲落故衣。","王维《山居即事》"] +["风含翠篠娟娟净,雨裛红蕖冉冉香。","杜甫《狂夫》"] +["桃红复含宿雨,柳绿更带朝烟。","王维《田园乐七首·其六》"] +["棠梨叶落胭脂色,荞麦花开白雪香。","王禹偁《村行·马穿山径菊初黄》"] +["萋萋芳草忆王孙。柳外楼高空断魂。","李重元《忆王孙·春词》"] +["渡头杨柳青青。枝枝叶叶离情。","晏几道《清平乐·留人不住》"] +["柳条百尺拂银塘,且莫深青只浅黄。","杨万里《新柳》"] +["西风乱叶溪桥树。秋在黄花羞涩处","张榘《青玉案·被檄出郊题陈氏山居》"] +["历冰霜、不变好风姿,温如玉。","陆容《满江红·咏竹》"] +["卧看牵牛织女星,月转过梧桐树影。","卢挚《沉醉东风·七夕》"] +["迟迟钟鼓初长夜,耿耿星河欲曙天。","白居易《长恨歌》"] +["一声已动物皆静,四座无言星欲稀。","李颀《琴歌》"] +["乌鹊倦栖,鱼龙惊起,星斗挂垂杨。","陈亮《一丛花·溪堂玩月作》"] +["游子乍闻征袖湿,佳人才唱翠眉低。","郑谷《鹧鸪》"] +["一日不读书,胸臆无佳想。","萧抡谓《读书有所见作》"] +["七国三边未到忧,十三身袭富平侯。","李商隐《富平少侯》"] +["夜饮东坡醒复醉,归来仿佛三更。家童鼻息已雷鸣。","苏轼《临江仙·夜饮东坡醒复醉》"] +["绕池闲步看鱼游,正值儿童弄钓舟。","白居易《观游鱼》"] +["暗尘随马去,明月逐人来。","苏味道《正月十五夜》"] +["虑澹物自轻,意惬理无违。","谢灵运《石壁精舍还湖中作》"] +["记得那年花下,深夜,初识谢娘时。","韦庄《荷叶杯·记得那年花下》"] +["冉冉年华吾自老。水满汀洲,何处寻芳草。","辛弃疾《蝶恋花·月下醉书雨岩石浪》"] +["竹色溪下绿,荷花镜里香。","李白《别储邕之剡中》"] +["清江一曲抱村流,长夏江村事事幽。","杜甫《江村》"] +["青梅如豆柳如眉,日长蝴蝶飞。","欧阳修《阮郎归·南园春半踏青时》"] +["蛱蝶飞来过墙去,却疑春色在邻家。","王驾《雨晴》"] +["孤村芳草远,斜日杏花飞。","寇准《江南春·波渺渺》"] +["五原春色旧来迟,二月垂杨未挂丝。","张敬忠《边词》"] +["春无踪迹谁知。除非问取黄鹂。","黄庭坚《清平乐·春归何处》"] +["柳外轻雷池上雨,雨声滴碎荷声。","欧阳修《临江仙·柳外轻雷池上雨》"] +["菱叶萦波荷飐风,荷花深处小船通。","白居易《采莲曲》"] +["一番荷芰生池沼,槛前风送馨香。","尹鹗《临江仙·一番荷芰生池沼》"] +["深夜无风新雨歇,凉月,露迎珠颗入圆荷。","阎选《定风波·江水沉沉帆影过》"] +["八月秋高风怒号,卷我屋上三重茅。","杜甫《茅屋为秋风所破歌》"] +["木落雁南度,北风江上寒。","孟浩然《早寒江上有怀 / 早寒有怀 / 江上思归》"] +["休问梁园旧宾客,茂陵秋雨病相如。","李商隐《寄令狐郎中》"] +["不觉碧山暮,秋云暗几重。","李白《听蜀僧濬弹琴 / 听蜀僧浚弹琴》"] +["未必素娥无怅恨,玉蟾清冷桂花孤。","晏殊《中秋月》"] +["船动湖光滟滟秋,贪看年少信船流。","皇甫松《采莲子·船动湖光滟滟秋》"] +["今日暂同芳菊酒,明朝应作断蓬飞。","王之涣《九日送别》"] +["城外萧萧北风起,城上健儿吹落耳。","刘基《北风行》"] +["长记曾携手处,千树压、西湖寒碧。","姜夔《暗香·旧时月色》"] +["惊风乱飐芙蓉水,密雨斜侵薜荔墙。","柳宗元《登柳州城楼寄漳汀封连四州》"] +["松间沙路净无泥,潇潇暮雨子规啼。","苏轼《浣溪沙·游蕲水清泉寺》"] +["云销雨霁,彩彻区明。","王勃《滕王阁序》"] +["不知江月待何人,但见长江送流水。","张若虚《春江花月夜》"] +["城里夕阳城外雪,相将十里异阴晴。","王守仁《次韵陆佥宪元日春晴》"] +["江阔云低、断雁叫西风。","蒋捷《虞美人·听雨》"] +["子胥既弃吴江上,屈原终投湘水滨。","李白《行路难三首》"] +["浓似春云淡似烟,参差绿到大江边。","纪昀《富春至严陵山水甚佳》"] +["冰雪襟怀,琉璃世界,夜气清如许。","黄升《酹江月·夜凉》"] +["十月江南天气好,可怜冬景似春华。","白居易《早冬》"] +["六出飞花入户时,坐看青竹变琼枝。","高骈《对雪》"] +["马毛带雪汗气蒸,五花连钱旋作冰,幕中草檄砚水凝。","岑参《走马川行奉送出师西征 / 走马川行奉送封大夫出师西征》"] +["更无花态度,全有雪精神。","辛弃疾《临江仙·探梅》"] +["犬吠水声中,桃花带露浓。","李白《访戴天山道士不遇》"] +["两水夹明镜,双桥落彩虹。","李白《秋登宣城谢脁北楼》"] +["群山万壑赴荆门,生长明妃尚有村。","杜甫《咏怀古迹五首·其三》"] +["雨落不上天,水覆难再收。","李白《妾薄命》"] +["斜阳外,寒鸦万点,流水绕孤村。","秦观《满庭芳·山抹微云》"] +["眼见的吹翻了这家,吹伤了那家,只吹的水尽鹅飞罢!","王磐《朝天子·咏喇叭》"] +["上有青冥之长天,下有渌水之波澜。","李白《长相思·其一》"] +["关山别荡子,风月守空闺。","薛道衡《昔昔盐》"] +["夫死战场子在腹,妾身虽存如昼烛。","张籍《征妇怨》"] +["乌啼隐杨花,君醉留妾家。","李白《杨叛儿》"] +["桃花尽日随流水,洞在清溪何处边。","张旭《桃花溪》"] +["玉露凋伤枫树林,巫山巫峡气萧森。","杜甫《秋兴八首·其一》"] +["行人无限秋风思,隔水青山似故乡。","戴叔伦《题稚川山水》"] +["夜发清溪向三峡,思君不见下渝州。","李白《峨眉山月歌》"] +["沉恨细思,不如桃杏,犹解嫁东风。","张先《一丛花令·伤高怀远几时穷》"] +["二月江南花满枝,他乡寒食远堪悲。","孟云卿《寒食》"] +["古台摇落后,秋日望乡心。","刘长卿《秋日登吴公台上寺远眺》"] +["故人具鸡黍,邀我至田家。","孟浩然《过故人庄》"] +["尊前故人如在,想念我、最关情。","周邦彦《绮寮怨·上马人扶残醉》"] +["故人何在,水村山郭。","孙道绚《忆秦娥·季温老友归樵阳人来闲书因以为寄》"] +["送君归去愁不尽,又惜空度凉风天。","王昌龄《送狄宗亨》"] +["此去与师谁共到,一船明月一帆风。","韦庄《送日本国僧敬龙归》"] +["入春才七日,离家已二年。","薛道衡《人日思归》"] +["歌泣不成天已暮,悲风日夜起江生。","魏禧《登雨花台》"] +["明朝寒食了,又是一年春。","顾太清《临江仙·清明前一日种海棠》"] +["春眠不觉晓,处处闻啼鸟。","孟浩然《春晓》"] +["春雨断桥人不度,小舟撑出柳阴来。","徐俯《春游湖》"] +["夜来风雨声,花落知多少。","孟浩然《春晓》"] +["满地芦花和我老,旧家燕子傍谁飞?","文天祥《金陵驿二首·其一》"] +["曾伴浮云归晚翠,犹陪落日泛秋声。","高蟾《金陵晚望》"] +["山围故国周遭在,潮打空城寂寞回。","刘禹锡《金陵五题·石头城》"] +["蝴蝶不传千里梦,子规叫断三更月。","辛弃疾《满江红·点火樱桃》"] +["投躯报明主,身死为国殇。","鲍照《代出自蓟北门行》"] +["如何亡国恨,尽在大江东!","屈大均《秣陵》"] +["去国十年老尽、少年心。","黄庭坚《虞美人·宜州见梅作》"] +["小轩窗。正梳妆。相顾无言,惟有泪千行。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["伫立伤神。无奈轻寒著摸人。","朱淑真《减字木兰花·春怨》"] +["夜深风竹敲秋韵。万叶千声皆是恨。","欧阳修《玉楼春·别后不知君远近》"] +["玉勒雕鞍游冶处,楼高不见章台路。","欧阳修《蝶恋花·庭院深深深几许》"] +["幽人归独卧,滞虑洗孤清。","张九龄《感遇十二首》"] +["语来江色暮,独自下寒烟。","李白《寻雍尊师隐居》"] +["聚散匆匆,云边孤雁,水上浮萍。","刘过《柳梢青·送卢梅坡》"] +["空自觉、围羞带减,影怯灯孤。","张炎《渡江云·山阴久客一再逢春回忆西杭渺然愁思》"] +["却将万字平戎策,换得东家种树书。","辛弃疾《鹧鸪天·有客慨然谈功名因追念少年时事戏作》"] +["香帏风动花入楼,高调鸣筝缓夜愁。","王昌龄《青楼怨》"] +["半死梧桐老病身,重泉一念一伤神。","白居易《为薛台悼亡》"] +["衰柳寒蝉一片愁,谁肯教白衣送酒?","关汉卿《沉醉东风·重九》"] +["教人怎不伤情。觉几度、魂飞梦惊。","刘过《柳梢青·送卢梅坡》"] +["又到断肠回首处,泪偷零。","纳兰性德《山花子·风絮飘残已化萍》"] +["紫艳半开篱菊静,红衣落尽渚莲愁。","赵嘏《长安晚秋 / 秋望 / 秋夕》"] +["可惜一片清歌,都付与黄昏。","黄孝迈《湘春夜月·近清明》"] +["伤心秦汉,生民涂炭,读书人一声长叹。","张可久《卖花声·怀古》"] +["伤心莫问前朝事,重上越王台。","倪瓒《人月圆·伤心莫问前朝事》"] +["谁谓含愁独不见,更教明月照流黄。","沈佺期《古意呈补阙乔知之 / 古意 / 独不见》"] +["百啭无人能解,因风飞过蔷薇。","黄庭坚《清平乐·春归何处》"] +["曲阑深处重相见,匀泪偎人颤。","纳兰性德《虞美人·曲阑深处重相见》"] +["且看欲尽花经眼,莫厌伤多酒入唇。","杜甫《曲江二首》"] +["所谓伊人,在水一方。","佚名《蒹葭》"] +["为人性僻耽佳句,语不惊人死不休。","杜甫《江上值水如海势聊短述》"] +["尔曹身与名俱灭,不废江河万古流。","杜甫《戏为六绝句·其二》"] +["断香残酒情怀恶。西风催衬梧桐落。","李清照《忆秦娥·咏桐》"] +["铁衣霜露重,战马岁年深。","戎昱《塞下曲·其六》"] +["去年燕子天涯,今年燕子谁家?","张炎《清平乐·采芳人杳》"] +["惊塞雁,起城乌,画屏金鹧鸪。","温庭筠《更漏子·柳丝长》"] +["灞桥烟柳,曲江池馆,应待人来。","陆游《秋波媚·七月十六日晚登高兴亭望长安南山》"] +["五更钟动笙歌散,十里月明灯火稀。","贺铸《思越人·紫府东风放夜时》"] +["好时节,愿得年年,常见中秋月。","徐有贞《中秋月·中秋月》"] +["至今不会天中事,应是嫦娥掷与人。","皮日休《天竺寺八月十五日夜桂子》"] +["碧艾香蒲处处忙。谁家儿共女,庆端阳。","舒頔《小重山·端午》"] +["春去花还在,人来鸟不惊。","王维《画》"] +["打起黄莺儿,莫教枝上啼。","金昌绪《春怨 / 伊州歌》"] +["九日明朝酒香,一年好景橙黄。","张可久《满庭芳·客中九日》"] +["霜草苍苍虫切切,村南村北行人绝。","白居易《村夜》"] +["钓罢归来不系船,江村月落正堪眠。","司空曙《江村即事》"] +["此日六军同驻马,当时七夕笑牵牛。","李商隐《马嵬·其二》"] +["何计长来此,闲眠过一生。","姚合《秋夜月中登天坛》"] +["愿天上人间,占得欢娱,年年今夜。","柳永《二郎神·炎光谢》"] +["无田似我犹欣舞,何况田间望岁心。","曾几《苏秀道中》"] +["十里西畴熟稻香,槿花篱落竹丝长,垂垂山果挂青黄。","范成大《浣溪沙·江村道中》"] +["开荒南野际,守拙归园田。","陶渊明《归园田居·其一》"] +["时人不识农家苦,将谓田中谷自生。","颜仁郁《农家》"] +["田夫荷锄至,相见语依依。","王维《渭川田家》"] +["江村独归处,寂寞养残生。","杜甫《奉济驿重送严公四韵》"] +["一帘鸠外雨,几处闲田,隔水动春锄。","张炎《渡江云·山阴久客一再逢春回忆西杭渺然愁思》"] +["落花水香茅舍晚,断桥头卖鱼人散。","马致远《寿阳曲·远浦帆归》"] +["驿路侵斜月,溪桥度晓霜。","吕本中《南歌子·驿路侵斜月》"] +["君独不见长城下,死人骸骨相撑拄。","陈琳《饮马长城窟行》"] +["灯火纸窗修竹里,读书声。","陈继儒《浣溪沙·初夏夜饮归》"] +["簌簌衣巾落枣花,村南村北响缫车,牛衣古柳卖黄瓜。","苏轼《浣溪沙·簌簌衣巾落枣花》"] +["万壑有声含晚籁,数峰无语立斜阳。","王禹偁《村行·马穿山径菊初黄》"] +["催促年光,旧来流水知何处。","廖世美《烛影摇红·题安陆浮云楼》"] +["楼台处处迷芳草,风雨年年怨落花。","曾棨《维扬怀古》"] +["怨别自惊千里外,论交却忆十年时。","高适《东平别前卫县李寀少府 / 送前卫县李宷少府》"] +["万物迎春送残腊,一年结局在今宵。","戴复古《除夜》"] +["日晚江南望江北,寒鸦飞尽水悠悠。","严维《丹阳送韦参军》"] +["阳月南飞雁,传闻至此回。","宋之问《题大庾岭北驿》"] +["满月飞明镜,归心折大刀。","杜甫《八月十五夜月二首》"] +["一月可曾闲几日,百年难得闰中秋。","慧霖《闰中秋玩月》"] +["听风听雨过清明。愁草瘗花铭。","吴文英《风入松·听风听雨过清明》"] +["往年同在鸾桥上,见倚朱阑咏柳绵。","韩偓《寒食日重游李氏园亭有怀》"] +["还背垂虹秋去,四桥烟雨,一宵歌酒。","吴文英《瑞龙吟·送梅津》"] +["桥北桥南千百树,绿烟金穗映清流。","朱栴《官桥柳色》"] +["落日胡尘未断,西风塞马空肥。","辛弃疾《木兰花慢·席上送张仲固帅兴元》"] +["云开汶水孤帆远,路绕梁山匹马迟。","高适《东平别前卫县李寀少府 / 送前卫县李宷少府》"] +["猎人箭底求伤雁,钓户竿头乞活鱼。","王建《寄旧山僧》"] +["别日南鸿才北去,今朝北雁又南飞。","鱼玄机《闺怨》"] +["黄鸟翩翩杨柳垂,春风送客使人悲。","高适《东平别前卫县李寀少府 / 送前卫县李宷少府》"] +["微吟不道惊溪鸟,飞入乱云深处啼。","沈周《题画》"] +["琉璃钟,琥珀浓,小槽酒滴真珠红。","李贺《将进酒》"] +["漫惹炉烟双袖紫,空将酒晕一衫青。","纳兰性德《浣溪沙·伏雨朝寒愁不胜》"] +["感之欲叹息,对酒还自倾。","李白《春日醉起言志》"] +["对酒当歌,强乐还无味。","柳永《蝶恋花·伫倚危楼风细细》"] +["春江花朝秋月夜,往往取酒还独倾。","白居易《琵琶行 / 琵琶引》"] +["巧笑倩兮,美目盼兮。","佚名《硕人》"] +["见梨花初带夜月,海棠半含朝雨。","万俟咏《三台·清明应制》"] +["冷艳全欺雪,余香乍入衣。","丘为《左掖梨花》"] +["落尽梨花春又了。满地残阳,翠色和烟老。","梅尧臣《苏幕遮·草》"] +["我劝天公重抖擞,不拘一格降人才。","龚自珍《己亥杂诗·其二百二十》"] +["一川烟草,满城风絮,梅子黄时雨。","贺铸《青玉案·凌波不过横塘路》"] +["黄四娘家花满蹊,千朵万朵压枝低。","杜甫《江畔独步寻花七绝句》"] +["李白能诗复能酒,我今百杯复千首。","唐寅《把酒对月歌》"] +["花开堪折直须折,莫待无花空折枝。","佚名《金缕衣》"] +["城中桃李愁风雨,春在溪头荠菜花。","辛弃疾《鹧鸪天·陌上柔桑破嫩芽》"] +["已是黄昏独自愁,更著风和雨。","陆游《卜算子·咏梅》"] +["雨恨云愁,江南依旧称佳丽。","王禹偁《点绛唇·感兴》"] +["南朝千古伤心事,犹唱后庭花。","吴激《人月圆·南朝千古伤心事》"] +["新妆宜面下朱楼,深锁春光一院愁。","刘禹锡《和乐天春词 / 春词》"] +["薄雾浓云愁永昼,瑞脑销金兽。","李清照《醉花阴·薄雾浓云愁永昼》"] +["剑花寒,夜坐归心壮,又是他乡。","张可久《满庭芳·客中九日》"] +["万事一身伤老矣,戎葵凝笑墙东。","陈与义《临江仙·高咏楚词酬午日》"] +["分明一觉华胥梦,回首东风泪满衣。","赵鼎《鹧鸪天·建康上元作》"] +["恋树湿花飞不起,愁无比,和春付与东流水。","朱服《渔家傲·小雨纤纤风细细》"] +["今夜送归灯火冷,河塘,堕泪羊公却姓杨。","苏轼《南乡子·和杨元素时移守密州》"] +["泪湿罗衣脂粉满。四叠阳关,唱到千千遍。","李清照《蝶恋花·泪湿罗衣脂粉满》"] +["请为父老歌:艰难愧深情!","杜甫《羌村三首·其三》"] +["荷花娇欲语,愁杀荡舟人。","李白《渌水曲》"] +["天上星河转,人间帘幕垂。凉生枕簟泪痕滋。","李清照《南歌子·天上星河转》"] +["寄语天涯客,轻寒底用愁。","于谦《除夜太原寒甚》"] +["曾苦伤春不忍听,凤城何处有花枝。","李商隐《流莺》"] +["如今风雨西楼夜,不听清歌也泪垂。","周紫芝《鹧鸪天·一点残红欲尽时》"] +["凤凋碧柳愁眉淡,露染黄花笑靥深。","晏几道《鹧鸪天·九日悲秋不到心》"] +["豪华尽成春梦,留下古今愁。","康与之《诉衷情令·长安怀古》"] +["时危见臣节,世乱识忠良。","鲍照《代出自蓟北门行》"] +["当时相候赤栏桥,今日独寻黄叶路。","周邦彦《玉楼春·桃溪不作从容住》"] +["回廊四合掩寂寞,碧鹦鹉对红蔷薇。","李商隐《日射》"] +["绿兮丝兮,女所治兮。我思古人,俾无訧兮!","佚名《绿衣》"] +["幽兰旋老,杜若还生,水乡尚寄旅。","吴文英《莺啼序·春晚感怀》"] +["满地黄花堆积。憔悴损,如今有谁堪摘?","李清照《声声慢·寻寻觅觅》"] +["胜地不常,盛筵难再;兰亭已矣,梓泽丘墟。","王勃《滕王阁序》"] +["玉树歌残王气终,景阳兵合戍楼空。","许浑《金陵怀古》"] +["桃花春水渌,水上鸳鸯浴。","韦庄《菩萨蛮》"] +["谁言千里自今夕,离梦杳如关塞长。","薛涛《送友人》"] +["丹阳郭里送行舟,一别心知两地秋。","严维《丹阳送韦参军》"] +["薄衾小枕凉天气,乍觉别离滋味。","柳永《忆帝京·薄衾小枕凉天气》"] +["九月九日望乡台,他席他乡送客杯。","王勃《蜀中九日 / 九日登高》"] +["远送从此别,青山空复情。","杜甫《奉济驿重送严公四韵》"] +["乡书何处达?归雁洛阳边。","王湾《次北固山下》"] +["请君看取东流水,方识人间别意长。","严仁《鹧鸪天·惜别》"] +["故人何在,烟水茫茫。","柳永《玉蝴蝶·望处雨收云断》"] +["细看来,不是杨花,点点是离人泪。","苏轼《水龙吟·次韵章质夫杨花词》"] +["不用诉离觞,痛饮从来别有肠。","苏轼《南乡子·和杨元素时移守密州》"] +["已过才追问,相看是故人。","吴伟业《遇旧友》"] +["欲寻芳草去,惜与故人违。","孟浩然《留别王侍御维 / 留别王维》"] +["欢笑情如旧,萧疏鬓已斑。","韦应物《淮上喜会梁川故人 / 淮上喜会梁州故人》"] +["碛里征人三十万,一时回向月明看。","李益《从军北征》"] +["春风一夜吹乡梦,又逐春风到洛城。","武元衡《春兴》"] +["远梦归侵晓,家书到隔年。","杜牧《旅宿》"] +["行多有病住无粮,万里还乡未到乡。","卢纶《逢病军人》"] +["如今白首乡心尽,万里归程在梦中。","清江《送婆罗门》"] +["画图恰似归家梦,千里河山寸许长。","辛弃疾《鹧鸪天·送元济之归豫章》"] +["画图省识春风面,环佩空归夜月魂。","杜甫《咏怀古迹五首·其三》"] +["悲歌可以当泣,远望可以当归。","佚名《悲歌》"] +["思君若汶水,浩荡寄南征。","李白《沙丘城下寄杜甫》"] +["徘徊将何见?忧思独伤心。","阮籍《咏怀八十二首·其一》"] +["目送征鸿飞杳杳,思随流水去茫茫。","孙光宪《浣溪沙·蓼岸风多橘柚香》"] +["血染红笺,泪题锦句。西湖岂忆相思苦。","刘将孙《踏莎行·闲游》"] +["柳色披衫金缕凤,纤手轻拈红豆弄,翠蛾双敛正含情。","和凝《天仙子·柳色披衫金缕凤》"] +["相思已是不曾闲,又那得、工夫咒你。","蜀妓《鹊桥仙·说盟说誓》"] +["月色入高楼,相思两处愁。","秋瑾《菩萨蛮·寄女伴》"] +["为君憔悴尽,百花时。","温庭筠《南歌子·倭堕低梳髻》"] +["薄幸郎君何日到,想自当初,莫要相逢好。","施耐庵《蝶恋花·一别家山音信杳》"] +["屏山遮断相思路,子规啼到无声处。","高鹗《青玉案·丝丝香篆浓于雾》"] +["相思处、青年如梦,乘鸾仙阙。","文天祥《满江红·和王夫人满江红韵以庶几後山妾薄命之意》"] +["树树皆秋色,山山唯落晖。","王绩《野望》"] +["海阔山遥,未知何处是潇湘。","柳永《玉蝴蝶·望处雨收云断》"] +["白酒新熟山中归,黄鸡啄黍秋正肥。","李白《南陵别儿童入京》"] +["舍南舍北皆春水,但见群鸥日日来。","杜甫《客至》"] +["纷纷红紫已成尘,布谷声中夏令新。","陆游《初夏绝句》"] +["老柘叶黄如嫩树,寒樱枝白是狂花。","白居易《早冬》"] +["剑河风急雪片阔,沙口石冻马蹄脱。","岑参《轮台歌奉送封大夫出师西征》"] +["朔风吹散三更雪,倩魂犹恋桃花月。","纳兰性德《菩萨蛮·朔风吹散三更雪》"] +["横笛闻声不见人,红旗直上天山雪。","陈羽《从军行》"] +["纷纷暮雪下辕门,风掣红旗冻不翻。","岑参《白雪歌送武判官归京》"] +["隔牖风惊竹,开门雪满山。","王维《冬晚对雪忆胡居士家》"] +["日暮平沙秋草乱,一双白鸟避人飞。","袁宏道《感事·湘山晴色远微微》"] +["燕支长寒雪作花,蛾眉憔悴没胡沙。","李白《王昭君二首》"] +["向来冰雪凝严地,力斡春回竟是谁?","陆游《落梅》"] +["相呼相应湘江阔,苦竹丛深日向西。","郑谷《鹧鸪》"] +["造化钟神秀,阴阳割昏晓。","杜甫《望岳》"] +["阁中帝子今何在?槛外长江空自流。","王勃《滕王阁序》"] +["鸟向檐上飞,云从窗里出。","吴均《山中杂诗》"] +["数点雨声风约住。朦胧淡月云来去。","李冠《蝶恋花·春暮》"] +["举头望云林,愧听慧鸟语。","王冕《墨萱图·其一》"] +["江山故宅空文藻,云雨荒台岂梦思。","杜甫《咏怀古迹五首·其二》"] +["天外黑风吹海立,浙东飞雨过江来。","苏轼《有美堂暴雨》"] +["雨前初见花间蕊,雨后兼无叶里花。","王驾《雨晴》"] +["滞雨通宵又彻明,百忧如草雨中生。","薛逢《长安夜雨》"] +["微雨从东来,好风与之俱。","陶渊明《读山海经·其一》"] +["江上阴云锁梦魂,江边深夜舞刘琨。","谭用之《秋宿湘江遇雨》"] +["碧云无渡碧天沉,是湖心,是侬心。","张惠言《江城子·填张春溪西湖竹枝词》"] +["千里澄江似练,翠峰如簇。","王安石《桂枝香·登临送目》"] +["湛湛长江去,冥冥细雨来。","杜甫《梅雨》"] +["穿天透地不辞劳,到底方知出处高。","冯云山《咏瀑布》"] +["卷地风来忽吹散,望湖楼下水如天。","苏轼《六月二十七日望湖楼醉书》"] +["无奈朝来寒雨,晚来风。","李煜《相见欢·林花谢了春红》"] +["春风桃李花开日,秋雨梧桐叶落时。","白居易《长恨歌》"] +["风劲角弓鸣,将军猎渭城。","王维《观猎》"] +["秋在水清山暮蝉,洛阳树色鸣皋烟。","王昌龄《送狄宗亨》"] +["萧萧远树疏林外,一半秋山带夕阳。","寇准《书河上亭壁》"] +["繁霜尽是心头血,洒向千峰秋叶丹。","戚继光《望阙台》"] +["微阳下乔木,远色隐秋山。","马戴《落日怅望》"] +["琴书倦,鹧鸪唤起南窗睡。","谢逸《千秋岁·咏夏景》"] +["雾柳暗时云度月,露荷翻处水流萤。","张元干《浣溪沙·山绕平湖波撼城》"] +["鸡鸣紫陌曙光寒,莺啭皇州春色阑。","岑参《奉和中书舍人贾至早朝大明宫》"] +["长江春水绿堪染,莲叶出水大如钱。","张籍《春别曲》"] +["蜂争粉蕊蝶分香,不似垂杨惜金缕。","温庭筠《惜春词》"] +["春风疑不到天涯,二月山城未见花。","欧阳修《戏答元珍》"] +["曾逐东风拂舞筵,乐游春苑断肠天。","李商隐《柳》"] +["堤上游人逐画船,拍堤春水四垂天。","欧阳修《浣溪沙·堤上游人逐画船》"] +["一夕轻雷落万丝,霁光浮瓦碧参差。","秦观《春日》"] +["映阶碧草自春色,隔叶黄鹂空好音。","杜甫《蜀相》"] +["东园载酒西园醉,摘尽枇杷一树金。","戴敏《初夏游张园》"] +["若耶溪傍采莲女,笑隔荷花共人语。","李白《采莲曲》"] +["仲夏苦夜短,开轩纳微凉。","杜甫《夏夜叹》"] +["知是人家花落尽,菜畦今日蝶来多。","高启《春暮西园》"] +["莫避春阴上马迟。春来未有不阴时。","辛弃疾《鹧鸪天·送欧阳国瑞入吴中》"] +["鹅鸭不知春去尽,争随流水趁桃花。","晁冲之《春日·阴阴溪曲绿交加》"] +["又是春将暮,无语对斜阳。","葛长庚《水调歌头·江上春山远》"] +["彩袖殷勤捧玉钟。当年拚却醉颜红。","晏几道《鹧鸪天·彩袖殷勤捧玉钟》"] +["和戎诏下十五年,将军不战空临边。","陆游《关山月》"] +["莫见长安行乐处,空令岁月易蹉跎。","李颀《送魏万之京》"] +["今年元夜时,月与灯依旧。","欧阳修《生查子·元夕》"] +["春风不解禁杨花,蒙蒙乱扑行人面。","晏殊《踏莎行·小径红稀》"] +["四十三年,望中犹记,烽火扬州路。","辛弃疾《永遇乐·京口北固亭怀古》"] +["父老得书知我在,小轩临水为君开。","苏轼《送贾讷倅眉》"] +["见面怜清瘦,呼儿问苦辛。","蒋士铨《岁暮到家》"] +["慈母倚门情,游子行路苦。","王冕《墨萱图·其一》"] +["玉人初上木兰时。懒妆斜立澹春姿。","吴文英《浣溪沙·题李中斋舟中梅屏》"] +["小儿呼叫走长街,云有痴呆召人买。","范成大《卖痴呆词》"] +["重重叠叠上瑶台,几度呼童扫不开。","苏轼《花影》"] +["鞠躬尽瘁,死而后已。","诸葛亮《后出师表》"] +["蚕娘洗茧前溪渌,牧童吹笛和衣浴。","贯休《春晚书山家屋壁二首》"] +["会稽愚妇轻买臣,余亦辞家西入秦。","李白《南陵别儿童入京》"] +["遥怜小儿女,未解忆长安。","杜甫《月夜》"] +["塞马一声嘶,残星拂大旗。","纳兰性德《菩萨蛮·朔风吹散三更雪》"] +["竹喧归浣女,莲动下渔舟。","王维《山居秋暝》"] +["灯火万家城四畔,星河一道水中央。","白居易《江楼夕望招客》"] +["长歌吟松风,曲尽河星稀。","李白《下终南山过斛斯山人宿置酒》"] +["草木摇杀气,星辰无光彩。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["明妃一朝西入胡,胡中美女多羞死。","李白《于阗采花》"] +["忆共锦衾无半缝,郎似桐花,妾似桐花凤。","王士祯《蝶恋花·和漱玉词》"] +["阿婆还似初笄女,头未梳成不许看。","袁枚《遣兴》"] +["汉帝重阿娇,贮之黄金屋。","李白《妾薄命》"] +["叶落风不起,山空花自红。","陈师道《妾薄命·为曾南丰作》"] +["井灶有遗处,桑竹残朽株。","陶渊明《归园田居·其四》"] +["野竹分青霭,飞泉挂碧峰。","李白《访戴天山道士不遇》"] +["梧桐叶上,点点露珠零。","尹鹗《临江仙·深秋寒夜银河静》"] +["纷纷坠叶飘香砌。夜寂静,寒声碎。","范仲淹《御街行·秋日怀旧》"] +["荒村带返照,落叶乱纷纷。","刘长卿《碧涧别墅喜皇甫侍御相访》"] +["黄菊开时伤聚散。曾记花前,共说深深愿。","晏几道《蝶恋花·黄菊开时伤聚散》"] +["菊暗荷枯一夜霜。新苞绿叶照林光。","苏轼《浣溪沙·咏橘》"] +["柔条纷冉冉,叶落何翩翩。","曹植《美女篇》"] +["繁枝容易纷纷落,嫩叶商量细细开。","杜甫《江畔独步寻花七绝句》"] +["灼灼野花香,依依金柳黄。","佚名《菩萨蛮·霏霏点点回塘雨》"] +["江山如有待,花柳自无私。","杜甫《后游》"] +["去年今日关山路,细雨梅花正断魂。","苏轼《正月二十日往岐亭郡人潘古郭三人送余于女王城东禅庄院》"] +["看来岂是寻常色,浓淡由他冰雪中。","曹雪芹《咏红梅花得“红”字》"] +["一帘红雨桃花谢,十里清阴柳影斜。","胡祗遹《阳春曲·春景》"] +["渔舟逐水爱山春,两岸桃花夹古津。","王维《桃源行》"] +["床前明月光,疑是地上霜。","李白《静夜思》"] +["日月忽其不淹兮,春与秋其代序。","屈原《离骚》"] +["晨兴理荒秽,带月荷锄归。","陶渊明《归园田居·其三》"] +["迁客此时徒极目,长洲孤月向谁明。","李白《鹦鹉洲》"] +["雨晴烟晚。绿水新池满。","冯延巳《清平乐·雨晴烟晚》"] +["柳花惊雪浦,麦雨涨溪田。","李贺《南园十三首》"] +["蜀天常夜雨,江槛已朝晴。","杜甫《水槛遣心二首》"] +["柳垂江上影,梅谢雪中枝。","晏几道《临江仙·身外闲愁空满》"] +["郎骑竹马来,绕床弄青梅。","李白《长干行二首》"] +["从别后,忆相逢,几回魂梦与君同。","晏几道《鹧鸪天·彩袖殷勤捧玉钟》"] +["十五从军征,八十始得归。","佚名《十五从军征》"] +["东临碣石,以观沧海。","曹操《观沧海》"] +["我有所念人,隔在远远乡。","白居易《夜雨》"] +["朱雀桥边野草花,乌衣巷口夕阳斜。","刘禹锡《乌衣巷》"] +["从此无心爱良夜,任他明月下西楼。","李益《写情》"] +["问汝平生功业,黄州惠州儋州。","苏轼《自题金山画像》"] +["周公恐惧流言日,王莽谦恭未篡时。","白居易《放言五首·其三》"] +["试问岭南应不好,却道:此心安处是吾乡。","苏轼《定风波·南海归赠王定国侍人寓娘》"] +["却道天凉好个秋。","辛弃疾《丑奴儿·书博山道中壁》"] +["何须浅碧深红色,自是花中第一流。","李清照《鹧鸪天·桂花》"] +["我自横刀向天笑,去留肝胆两昆仑。","谭嗣同《狱中题壁》"] +["孔雀东南飞,五里一徘徊。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["垆边人似月,皓腕凝霜雪。","韦庄《菩萨蛮·人人尽说江南好》"] +["人间辛苦是三农。","王炎《南柯子·山冥云阴重》"] +["明年此日青云去,却笑人间举子忙。","辛弃疾《鹧鸪天·送廓之秋试》"] +["记得绿罗裙,处处怜芳草。","贺铸《绿罗裙·东风柳陌长》"] +["八表同昏,平陆成江。","陶渊明《停云》"] +["寄意寒星荃不察,我以我血荐轩辕。","鲁迅《自题小像》"] +["泉涸,鱼相与处于陆,相呴以湿,相濡以沫,不如相忘于江湖。","《庄子·内篇·大宗师》"] +["至乐无乐,至誉无誉。","《庄子·外篇·至乐》"] +["宁我负人,毋人负我。","《三国志·魏书·武帝纪》"] +["宠辱不惊,闲看庭前花开花落;去留无意,漫随天外云卷云舒。","《菜根谭·概论》"] +["雨中黄叶树,灯下白头人。","司空曙《喜外弟卢纶见宿》"] +["岐王宅里寻常见,崔九堂前几度闻。","杜甫《江南逢李龟年》"] +["我今因病魂颠倒,唯梦闲人不梦君。","元稹《酬乐天频梦微之》"] +["洞房昨夜停红烛,待晓堂前拜舅姑。","朱庆馀《近试上张籍水部 / 近试上张水部 / 闺意献张水部》"] +["上邪,我欲与君相知,长命无绝衰。","佚名《上邪》"] +["可惜明年花更好,知与谁同?","欧阳修《浪淘沙·把酒祝东风》"] +["前不见古人,后不见来者。","陈子昂《登幽州台歌》"] +["青山一道同云雨,明月何曾是两乡。","王昌龄《送柴侍御》"] +["年年雪里。常插梅花醉。","李清照《清平乐·年年雪里》"] +["春还草阁梅先动,月满虚庭雪未消。","王守仁《元夕二首》"] +["骤雨过,珍珠乱撒,打遍新荷。","元好问《骤雨打新荷》"] +["谁家秋院无风入?何处秋窗无雨声?","曹雪芹《代别离·秋窗风雨夕》"] +["东风杨柳欲青青。烟淡雨初晴。","晏殊《诉衷情·东风杨柳欲青青》"] +["池塘过雨急鸣蛙。酒醒明月照窗纱。","夏言《浣溪沙·庭院沉沉白日斜》"] +["思牵今夜肠应直,雨冷香魂吊书客。","李贺《秋来》"] +["雨后双禽来占竹,秋深一蝶下寻花。","文同《北斋雨后》"] +["草色全经细雨湿,花枝欲动春风寒。","王维《酌酒与裴迪》"] +["雨洗娟娟净,风吹细细香。","杜甫《严郑公宅同咏竹》"] +["萤飞秋窗满,月度霜闺迟。","李白《塞下曲六首》"] +["走马西来欲到天,辞家见月两回圆。","岑参《碛中作》"] +["风鸣两岸叶,月照一孤舟。","孟浩然《宿桐庐江寄广陵旧游》"] +["挟飞仙以遨游,抱明月而长终。","苏轼《前赤壁赋》"] +["日月之行,若出其中。","曹操《观沧海 / 碣石篇》"] +["人攀明月不可得,月行却与人相随。","李白《把酒问月·故人贾淳令予问之》"] +["垂钓板桥东,雪压蓑衣冷。","释敬安《题寒江钓雪图》"] +["松排山面千重翠,月点波心一颗珠。","白居易《春题湖上》"] +["红日淡,绿烟晴。流莺三两声。","晏几道《更漏子·柳丝长》"] +["微雨过,小荷翻。榴花开欲然。","苏轼《阮郎归·初夏》"] +["画船撑入花深处,香泛金卮。","欧阳修《采桑子·荷花开后西湖好》"] +["手捻香笺忆小莲。欲将遗恨倩谁传。","晏几道《鹧鸪天·手捻香笺忆小莲》"] +["愿借天风吹得远,家家门巷尽成春。","李方膺《题画梅》"] +["候馆梅残,溪桥柳细。","欧阳修《踏莎行·候馆梅残》"] +["窗间梅熟落蒂,墙下笋成出林。","范成大《喜晴》"] +["烧痕惨淡带昏鸦,数尽寒梅未见花。","陈孚《咏永州》"] +["山上层层桃李花,云间烟火是人家。","刘禹锡《竹枝词九首·其九》"] +["常恐秋节至,焜黄华叶衰。","佚名《长歌行》"] +["吾生如寄,尚想三径菊花丛。","杨炎正《水调歌头·把酒对斜日》"] +["芙蓉如面柳如眉,对此如何不泪垂。","白居易《长恨歌》"] +["靖安宅里当窗柳,望驿台前扑地花。","白居易《望驿台》"] +["庭中有奇树,绿叶发华滋。","佚名《庭中有奇树》"] +["落叶人何在,寒云路几层。","李商隐《北青萝》"] +["松叶堪为酒,春来酿几多。","张九龄《答陆澧》"] +["荷叶五寸荷花娇,贴波不碍画船摇;","石涛《荷花》"] +["苍苍竹林寺,杳杳钟声晚。","刘长卿《送灵澈上人》"] +["林断山明竹隐墙。乱蝉衰草小池塘。","苏轼《鹧鸪天·林断山明竹隐墙》"] +["庭下如积水空明,水中藻、荇交横,盖竹柏影也。","苏轼《记承天寺夜游 / 记承天夜游》"] +["绿竹入幽径,青萝拂行衣。","李白《下终南山过斛斯山人宿置酒》"] +["水绕陂田竹绕篱,榆钱落尽槿花稀。","张舜民《村居·水绕陂田竹绕篱》"] +["久雨寒蝉少,空山落叶深。","秘演《山中》"] +["松竹翠萝寒,迟日江山暮。","曹组《卜算子·兰》"] +["灯前目力虽非昔,犹课蝇头二万言。","陆游《读书》"] +["出不入兮往不反,平原忽兮路超远。","屈原《国殇》"] +["去日儿童皆长大,昔年亲友半凋零。","窦叔向《夏夜宿表兄话旧》"] +["阿香秋梦起娇啼,玉女传幽素。","吴文英《烛影摇红·越上霖雨应祷》"] +["早知恁么。悔当初、不把雕鞍锁。","柳永《定风波·自春来》"] +["明月净松林,千峰同一色。","欧阳修《自菩提步月归广化寺》"] +["年少万兜鍪,坐断东南战未休。","辛弃疾《南乡子·登京口北固亭有怀》"] +["误落尘网中,一去三十年。","陶渊明《归园田居·其一》"] +["铜壶漏报天将晓,惆怅佳期又一年。","罗隐《七夕》"] +["长亭路,年去岁来,应折柔条过千尺。","周邦彦《兰陵王·柳》"] +["当年燕子知何处,但苔深韦曲,草暗斜川。","张炎《高阳台·西湖春感》"] +["华灯纵博,雕鞍驰射,谁记当年豪举。","陆游《鹊桥仙·华灯纵博》"] +["春风举国裁宫锦,半作障泥半作帆。","李商隐《隋宫 / 隋堤》"] +["春风江上路,不觉到君家。","高启《寻胡隐君》"] +["花时同醉破春愁,醉折花枝作酒筹。","白居易《同李十一醉忆元九》"] +["终古闲情归落照,一春幽梦逐游丝。","纳兰性德《浣溪沙·杨柳千条送马蹄》"] +["长恨春归无觅处,不知转入此中来。","白居易《大林寺桃花》"] +["天时人事日相催,冬至阳生春又来。","杜甫《小至》"] +["梨花风起正清明,游子寻春半出城。","吴惟信《苏堤清明即事》"] +["一树春风千万枝,嫩于金色软于丝。","白居易《杨柳枝词》"] +["瑶草一何碧,春入武陵溪。","黄庭坚《水调歌头·游览》"] +["问讯湖边春色,重来又是三年。","张孝祥《西江月·问讯湖边春色》"] +["云里帝城双凤阙,雨中春树万人家。","王维《奉和圣制从蓬莱向兴庆阁道中留春雨中春望之作应制》"] +["秦地罗敷女,采桑绿水边。","李白《子夜吴歌·春歌》"] +["柳梢听得黄鹂语,此是春来第一声。","杨载《到京师》"] +["二月湖水清,家家春鸟鸣。","孟浩然《春中喜王九相寻 / 晚春》"] +["白鸟朱荷引画桡,垂杨影里见红桥,欲寻往事已魂消。","王士祯《浣溪沙·红桥》"] +["笑指吾庐何处是?一池荷叶小桥横。","陈继儒《浣溪沙·初夏夜饮归》"] +["梅子青,梅子黄,菜肥麦熟养蚕忙。","祝允明《首夏山中行吟》"] +["晴云轻漾,熏风无浪,开樽避暑争相向。","薛昂夫《西湖杂咏·夏》"] +["春至花如锦,夏近叶成帷。","李爽《芳树》"] +["秋风萧瑟,洪波涌起。","曹操《观沧海 / 碣石篇》"] +["落日心犹壮,秋风病欲疏。","杜甫《江汉》"] +["明日巴陵道,秋山又几重。","李益《喜见外弟又言别》"] +["布被秋宵梦觉,眼前万里江山。","辛弃疾《清平乐·独宿博山王氏庵》"] +["今逢四海为家日,故垒萧萧芦荻秋。","刘禹锡《西塞山怀古》"] +["马穿山径菊初黄,信马悠悠野兴长。","王禹偁《村行·马穿山径菊初黄》"] +["梧桐真不甘衰谢,数叶迎风尚有声。","张耒《夜坐·庭户无人秋月明》"] +["秋夜访秋士,先闻水上音。","袁枚《夜过借园见主人坐月下吹笛》"] +["新寒中酒敲窗雨,残香细袅秋情绪。","纳兰性德《菩萨蛮·新寒中酒敲窗雨》"] +["秋鬓含霜白,衰颜倚酒红。","尹式《别宋常侍》"] +["鸟下绿芜秦苑夕,蝉鸣黄叶汉宫秋。","许浑《咸阳城东楼 / 咸阳城西楼晚眺 / 西门》"] +["尘中见月心亦闲,况是清秋仙府间。","刘禹锡《八月十五夜桃源玩月》"] +["云中君不见,竟夕自悲秋。","马戴《楚江怀古三首·其一》"] +["落日塞尘起,胡骑猎清秋。","辛弃疾《水调歌头·舟次扬州和人韵》"] +["高梧百尺夜苍苍,乱扫秋星落晓霜。","郑板桥《咏梧桐》"] +["凤林关里水东流,白草黄榆六十秋。","张籍《凉州词三首·其三》"] +["千里江山昨梦非,转眼秋光如许。","文征明《念奴娇·中秋对月》"] +["雨荒深院菊,霜倒半池莲。","杜甫《宿赞公房》"] +["昔看黄菊与君别,今听玄蝉我却回。","刘禹锡《始闻秋风》"] +["青天蜀道难,红叶吴江冷。","张可久《普天乐·秋怀》"] +["深秋绝塞谁相忆,木叶萧萧。","纳兰性德《采桑子·九日》"] +["浓雾知秋晨气润,薄云遮日午阴凉,不须飞盖护戎装。","范成大《浣溪沙·江村道中》"] +["天朗气清,惠风和畅","王羲之《兰亭集序 / 兰亭序》"] +["闺中风暖,陌上草薰。","江淹《别赋》"] +["小雨纤纤风细细,万家杨柳青烟里。","朱服《渔家傲·小雨纤纤风细细》"] +["雪暗凋旗画,风多杂鼓声。","杨炯《从军行》"] +["八月长江万里晴,千帆一道带风轻。","崔季卿《晴江秋望》"] +["白日地中出,黄河天外来。","张蠙《登单于台》"] +["日暮长江里,相邀归渡头。","储光羲《江南曲四首》"] +["嵩云秦树久离居,双鲤迢迢一纸书。","李商隐《寄令狐郎中》"] +["孤云与归鸟,千里片时间。","马戴《落日怅望》"] +["碧水惊秋,黄云凝暮,败叶零乱空阶。","秦观《满庭芳·碧水惊秋》"] +["女娲炼石补天处,石破天惊逗秋雨。","李贺《李凭箜篌引》"] +["月照城头乌半飞,霜凄万树风入衣。","李颀《琴歌》"] +["烟霏云敛;其容清明,天高日晶;","欧阳修《秋声赋》"] +["回看射雕处,千里暮云平。","王维《观猎》"] +["望长城内外,惟余莽莽;大河上下,顿失滔滔。","毛泽东《沁园春·雪》"] +["小舟横截春江,卧看翠壁红楼起。","苏轼《水龙吟·闾丘大夫孝终公显尝守黄州》"] +["江上往来人,但爱鲈鱼美。","范仲淹《江上渔者》"] +["江流石不转,遗恨失吞吴。","杜甫《八阵图》"] +["梅花大庾岭头发,柳絮章台街里飞。","李商隐《对雪二首》"] +["晴浦晚风寒,青山玉骨瘦。","董以宁《卜算子·雪江晴月(回文,倒读《巫山一段云》)》"] +["短篱残菊一枝黄。正是乱山深处、过重阳。","吕本中《南歌子·驿路侵斜月》"] +["况屈指中秋,十分好月,不照人圆。","辛弃疾《木兰花慢·滁州送范倅》"] +["西山白雪三城戍,南浦清江万里桥。","杜甫《野望》"] +["冬夜夜寒觉夜长,沉吟久坐坐北堂。","李白《夜坐吟》"] +["霜轻未杀萋萋草,日暖初干漠漠沙。","白居易《早冬》"] +["入世冷挑红雪去,离尘香割紫云来。","曹雪芹《访妙玉乞红梅》"] +["郴江幸自绕郴山,为谁流下潇湘去。","秦观《踏莎行·郴州旅舍》"] +["斜阳独倚西楼。遥山恰对帘钩。","晏殊《清平乐·红笺小字》"] +["枯枝无丑叶,涸水吐清泉。","李白《长歌行》"] +["知章骑马似乘船,眼花落井水底眠。","杜甫《饮中八仙歌》"] +["画栋朝飞南浦云,珠帘暮卷西山雨。","王勃《滕王阁诗》"] +["云青青兮欲雨,水澹澹兮生烟。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["春山暖日和风,阑干楼阁帘栊,杨柳秋千院中。","白朴《天净沙·春》"] +["山路元无雨,空翠湿人衣。","王维《山中》"] +["山中相送罢,日暮掩柴扉。","王维《送别 / 山中送别 / 送友》"] +["君在天一涯,妾身长别离。","江淹《古离别》"] +["紫燕一双娇语碎,翠屏十二晚峰齐,梦魂消散醉空闺。","毛熙震《浣溪沙·花榭香红烟景迷》"] +["还作一段相思,冷波叶舞愁红,送人双桨。","吴文英《解蹀躞·醉云又兼醒雨》"] +["结交在相知,骨肉何必亲。","佚名《箜篌谣》"] +["枕前泪共阶前雨,隔个窗儿滴到明。","聂胜琼《鹧鸪天·别情》"] +["寂寂花时闭院门,美人相并立琼轩。","朱庆馀《宫词 / 宫中词》"] +["今夜鄜州月,闺中只独看。","杜甫《月夜》"] +["断无蜂蝶慕幽香,红衣脱尽芳心苦。","贺铸《芳心苦·杨柳回塘》"] +["出入君怀袖,动摇微风发。","班婕妤《怨歌行》"] +["惜分长怕君先去,直待醉时休。","贺铸《眼儿媚·萧萧江上荻花秋》"] +["好水好山看不足,马蹄催趁月明归。","岳飞《池州翠微亭》"] +["今古长如白练飞,一条界破青山色。","徐凝《庐山瀑布》"] +["挂席几千里,名山都未逢。","孟浩然《晚泊浔阳望庐山》"] +["清风明月苦相思,荡子从戎十载馀。","王维《伊州歌》"] +["思来江山外,望尽烟云生。","张九龄《秋晚登楼望南江入始兴郡路》"] +["不堪肠断思乡处,红槿花中越鸟啼。","李德裕《谪岭南道中作》"] +["不信妾肠断,归来看取明镜前。","李白《长相思三首》"] +["相思休问定何如。情知春去后,管得落花无。","晁冲之《临江仙·忆昔西池池上饮》"] +["明朝且做莫思量,如何过得今宵去。","周紫芝《踏莎行·情似游丝》"] +["已恨碧山相阻隔,碧山还被暮云遮。","李觏《乡思》"] +["几度思归还把酒,拂云堆上祝明妃。","杜牧《题木兰庙》"] +["君似孤云何处归,我似离群雁。","周紫芝《卜算子·席上送王彦猷》"] +["终日两相思。为君憔悴尽,百花时。","温庭筠《南歌子·倭堕低梳髻》"] +["身多疾病思田里,邑有流亡愧俸钱。","韦应物《寄李儋元锡》"] +["无端更渡桑干水,却望并州是故乡。","刘皂《旅次朔方 / 渡桑干》"] +["怀旧空吟闻笛赋,到乡翻似烂柯人。","刘禹锡《酬乐天扬州初逢席上见赠》"] +["明眸皓齿谁复见,只有丹青余泪痕。","苏轼《虢国夫人夜游图》"] +["登临送目,正故国晚秋,天气初肃。","王安石《桂枝香·登临送目》"] +["江水三千里,家书十五行。","袁凯《京师得家书》"] +["三湘愁鬓逢秋色,万里归心对月明。","卢纶《晚次鄂州》"] +["满衣血泪与尘埃,乱后还乡亦可哀。","高启《送陈秀才还沙上省墓》"] +["今夜故人来不来,教人立尽梧桐影。","吕岩《梧桐影·落日斜》"] +["长条别有风流处,密映钱塘苏小家。","寇准《柳·晓带轻烟间杏花》"] +["别愁深夜雨,孤影小窗灯。","陈克《临江仙·四海十年兵不解》"] +["浩气清英,仙材卓荦,下土难分别。","丘处机《无俗念·灵虚宫梨花词》"] +["劝我早还家,绿窗人似花。","韦庄《菩萨蛮·红楼别夜堪惆怅》"] +["合欢桃核终堪恨,里许元来别有人。","温庭筠《南歌子词二首 / 新添声杨柳枝词》"] +["离心何以赠,自有玉壶冰。","骆宾王《送别》"] +["分手脱相赠,平生一片心。","孟浩然《送朱大入秦》"] +["离歌且莫翻新阕。一曲能教肠寸结。","欧阳修《玉楼春·尊前拟把归期说》"] +["支离东北风尘际,漂泊西南天地间。","杜甫《咏怀古迹五首·其一》"] +["不见南师久,谩说北群空。","陈亮《水调歌头·送章德茂大卿使虏》"] +["都缘自有离恨,故画作远山长。","欧阳修《诉衷情·眉意》"] +["天下伤心处,劳劳送客亭。","李白《劳劳亭》"] +["春日迟迟,卉木萋萋。","佚名《出车》"] +["云霞出海曙,梅柳渡江春。","杜审言《和晋陵陆丞早春游望》"] +["山河风景元无异,城郭人民半已非。","文天祥《金陵驿二首·其一》"] +["行人怅望苏台柳,曾与吴王扫落花。","姜夔《姑苏怀古》"] +["其人虽已没,千载有馀情。","陶渊明《咏荆轲》"] +["天子临轩赐侯印,将军佩出明光宫。","王维《少年行四首》"] +["缀玉联珠六十年,谁教冥路作诗仙。","李忱《吊白居易》"] +["尽道隋亡为此河,至今千里赖通波。","皮日休《汴河怀古二首》"] +["宿空房,秋夜长,夜长无寐天不明。","白居易《上阳白发人》"] +["困花压蕊丝丝雨,不堪只共愁人语。","徐灿《菩萨蛮·春闺》"] +["以我独沉久,愧君相见频。","司空曙《喜外弟卢纶见宿》"] +["故山知好在,孤客自悲凉。","苏轼《临江仙·送王缄》"] +["晚来天,空悄然,孤眠,枕檀云髻偏。","孙光宪《河传·花落》"] +["臣子恨,何时灭!驾长车,踏破贺兰山缺。","岳飞《满江红·写怀》"] +["一生傲岸苦不谐,恩疏媒劳志多乖。","李白《答王十二寒夜独酌有怀》"] +["今日独来香径里,更无人迹有苔钱。","韩偓《寒食日重游李氏园亭有怀》"] +["时见幽人独往来,缥缈孤鸿影。","苏轼《卜算子·黄州定慧院寓居作》"] +["秋草六朝寒,花雨空坛。更无人处一凭阑。","朱彝尊《卖花声·雨花台》"] +["舒卷江山图画,应答龙鱼悲啸,不暇顾诗愁。","杨炎正《水调歌头·登多景楼》"] +["满腹诗书漫古今,频年流落易伤心。","宇文虚中《在金日作·选二》"] +["这次第,算人间没个并刀,剪断心上愁痕。","黄孝迈《湘春夜月·近清明》"] +["花不语,水空流。年年拚得为花愁。","晏几道《鹧鸪天·守得莲开结伴游》"] +["天与秋光,转转情伤,探金英知近重阳。","李清照《行香子·天与秋光》"] +["愁无限,消瘦尽,有谁知?闲教玉笼鹦鹉念郎诗。","纳兰性德《相见欢·落花如梦凄迷》"] +["泪弹不尽临窗滴。就砚旋研墨。","晏几道《思远人·红叶黄花秋意晚》"] +["蜂儿不解知人苦,燕儿不解说人愁。","程垓《最高楼·旧时心事》"] +["永丰柳,无人尽日飞花雪。","张先《千秋岁·数声鶗鴂》"] +["不道愁人不喜听。空阶滴到明。","万俟咏《长相思·雨》"] +["忙日苦多闲日少,新愁常续旧愁生。","陆游《浣溪沙·和无咎韵》"] +["江心云带蒲帆重,楼上风吹粉泪香。","严仁《鹧鸪天·惜别》"] +["沧海客归珠有泪,章台人去骨遗香。","宋祁《落花》"] +["酒意诗情谁与共?泪融残粉花钿重。","李清照《蝶恋花·暖雨晴风初破冻》"] +["杨柳丝丝弄轻柔,烟缕织成愁。","王雱《眼儿媚·杨柳丝丝弄轻柔》"] +["东风不为吹愁去,春日偏能惹恨长。","贾至《春思二首·其一》"] +["涧户寂无人,纷纷开且落。","王维《辛夷坞》"] +["怅望千秋一洒泪,萧条异代不同时。","杜甫《咏怀古迹五首·其二》"] +["今年对花最匆匆,相逢似有恨,依依愁悴。","周邦彦《花犯·小石梅花》"] +["双燕飞来,陌上相逢否?撩乱春愁如柳絮。","冯延巳《鹊踏枝·几日行云何处去》"] +["念畴昔风流,暗伤如许。","柳如是《金明池·咏寒柳》"] +["芳菲歇。故园目断伤心切。","向子諲《秦楼月·芳菲歇》"] +["残月脸边明,别泪临清晓。","牛希济《生查子·春山烟欲收》"] +["男儿少为客,不辨是他乡。","李流芳《黄河夜泊》"] +["回首故山千里外,别离心绪向谁言?","杨徽之《寒食寄郑起侍郎》"] +["故乡遥,何日去。家住吴门,久作长安旅。","周邦彦《苏幕遮·燎沉香》"] +["醉里且贪欢笑,要愁那得工夫。","辛弃疾《西江月·遣兴》"] +["五更疏欲断,一树碧无情。","李商隐《蝉》"] +["倩何人唤取,红巾翠袖,揾英雄泪!","辛弃疾《水龙吟·登建康赏心亭》"] +["有谁知我此时情,枕前泪共阶前雨,隔个窗儿滴到明。","聂胜琼《鹧鸪天·别情》"] +["春雨楼头尺八箫,何时归看浙江潮?","苏曼殊《本事诗十首·选二》"] +["琴瑟在御,莫不静好。","佚名《女曰鸡鸣》"] +["二十四桥仍在,波心荡、冷月无声。","姜夔《扬州慢·淮左名都》"] +["一年好景君须记,最是橙黄橘绿时。","苏轼《赠刘景文 / 冬景》"] +["如切如磋,如琢如磨。","佚名《淇奥》"] +["战士军前半死生,美人帐下犹歌舞。","高适《燕歌行》"] +["老去逢春如病酒,唯有,茶瓯香篆小帘栊。","辛弃疾《定风波·暮春漫兴》"] +["漉我新熟酒,只鸡招近局。","陶渊明《归园田居·其五》"] +["落花狼藉酒阑珊,笙歌醉梦间。","李煜《阮郎归·呈郑王十二弟》"] +["为我引杯添酒饮,与君把箸击盘歌。","白居易《醉赠刘二十八使君》"] +["若对黄花孤负酒,怕黄花、也笑人岑寂。","刘克庄《贺新郎·九日》"] +["酌酒会临泉水,抱琴好倚长松。","王维《田园乐七首·其七》"] +["烟横水际,映带几点归鸦,东风销尽龙沙雪。","贺铸《石州引·薄雨初寒》"] +["燕子斜阳来又去,如此江山。","朱彝尊《卖花声·雨花台》"] +["游丝冉冉花枝静,青壁迢迢白鸟过。","王守仁《春晴》"] +["雁过斜阳,草迷烟渚。","周紫芝《踏莎行·情似游丝》"] +["洞庭一夜无穷雁,不待天明尽北飞。","李益《春夜闻笛》"] +["人语西风,瘦马嘶残月。","王国维《蝶恋花·满地霜华浓似雪》"] +["晚春盘马踏青苔,曾傍绿阴深驻。","晏几道《御街行·街南绿树春饶絮》"] +["客子过壕追野马,将军韬箭射天狼。","李梦阳《秋望》"] +["江上月明胡雁过,淮南木落楚山多。","刘长卿《江州重别薛六柳八二员外》"] +["山从人面起,云傍马头生。","李白《送友人入蜀》"] +["海棠枝上立多时,飞向小桥西畔去。","宋徵舆《玉楼春·燕雕梁画栋原无数》"] +["携扙来追柳外凉,画桥南畔倚胡床。","秦观《纳凉》"] +["好风胧月清明夜,碧砌红轩刺史家。","白居易《清明夜》"] +["屈子冤魂终古在,楚乡遗俗至今留。","边贡《午日观竞渡》"] +["清明又近也,却天涯为客。","曹组《忆少年·年时酒伴》"] +["画楼洗净鸳鸯瓦,彩绳半湿秋千架。","王元鼎《醉太平·寒食》"] +["织女牵牛送夕阳,临看不觉鹊桥长。","德容《七夕二首·其二》"] +["年年端午风兼雨,似为屈原陈昔冤。","赵蕃《端午三首》"] +["蜀国曾闻子规鸟,宣城还见杜鹃花。","李白《宣城见杜鹃花 / 子规》"] +["山气日夕佳,飞鸟相与还。","陶渊明《饮酒·其五》"] +["吊影分为千里雁,辞根散作九秋蓬。","白居易《望月有感》"] +["吟怀未许老重阳,霜雪无端入鬓长。","序灯《九日吴山宴集值雨次韵》"] +["绝景良时难再并,他年此日应惆怅。","刘禹锡《八月十五夜桃源玩月》"] +["年去年来白发新,匆匆马上又逢春。","于谦《立春日感怀》"] +["乃知兵者是凶器,圣人不得已而用之。","李白《战城南》"] +["满目山河增感慨,一时风景寄遨游。","姜塘《摘星楼九日登临》"] +["隐隐飞桥隔野烟,石矶西畔问渔船。","张旭《桃花溪》"] +["万里桥西一草堂,百花潭水即沧浪。","杜甫《狂夫》"] +["晓战随金鼓,宵眠抱玉鞍。","李白《塞下曲六首·其一》"] +["数丛沙草群鸥散,万顷江田一鹭飞。","温庭筠《利州南渡》"] +["斜阳照墟落,穷巷牛羊归。","王维《渭川田家》"] +["微雨众卉新,一雷惊蛰始。","韦应物《观田家》"] +["绿桑高下映平川,赛罢田神笑语喧。","欧阳修《田家》"] +["门外子规啼未休,山村落日梦悠悠。","陈与义《送人归京师》"] +["寒色孤村幕,悲风四野闻。","洪升《雪望》"] +["村北村南,谷雨才耕遍。","范成大《蝶恋花·春涨一篙添水面》"] +["过桥分野色,移石动云根。","贾岛《题李凝幽居》"] +["红板桥空,溅裙人去,依旧晓风残月。","纳兰性德《淡黄柳·咏柳》"] +["不知马骨伤寒水,惟见龙城起暮云。","王涯《塞下曲二首·其二》"] +["但从今、记取楚楼风,裴台月。","辛弃疾《满江红·汉水东流》"] +["万里不惜死,一朝得成功。","高适《塞下曲》"] +["迢递嵩高下,归来且闭关。","王维《归嵩山作》"] +["激气已能驱粉黛,举杯便可吞吴越。","史达祖《满江红·中秋夜潮》"] +["我闭南楼看道书,幽帘清寂在仙居。","李白《早秋单父南楼酬窦公衡》"] +["功名祗向马上取,真是英雄一丈夫。","岑参《送李副使赴碛西官军》"] +["去年寄书报阳台,今年寄书重相催。","李白《久别离》"] +["叹流年、又成虚度。","陆游《谢池春·壮岁从戎》"] +["想当年、空运筹决战,图王取霸无休。","柳永《双声子·晚天萧索》"] +["亭上秋风,记去年袅袅,曾到吾庐。","辛弃疾《汉宫春·会稽秋风亭观雨》"] +["昔叹当年无李牧,今知江左有夷吾。","陈亮《送文子转漕江东二首》"] +["始知锁向金笼听,不及林间自在啼。","欧阳修《画眉鸟》"] +["绿杨带雨垂垂重。五色新丝缠角粽。","欧阳修《渔家傲·五月榴花妖艳烘》"] +["双星良夜,耕慵织懒,应被群仙相妒。","范成大《鹊桥仙·七夕》"] +["山中夜来月,到晓不曾看。","元好问《倪庄中秋》"] +["暗柳啼鸦,单衣伫立,小帘朱户。","周邦彦《琐窗寒·寒食》"] +["芳洲拾翠暮忘归,秀野踏青来不定。","张先《木兰花·乙卯吴兴寒食》"] +["不是暗尘明月,那时元夜。","蒋捷《女冠子·元夕》"] +["听元宵,今岁嗟呀,愁也千家,怨也千家。","王磐《古蟾宫·元宵》"] +["几多情,无处说,落花飞絮清明节。","魏承班《渔歌子·柳如眉》"] +["拆桐花烂漫,乍疏雨、洗清明。","柳永《木兰花慢·拆桐花烂漫》"] +["齐唱宪王春乐府,金梁桥外月如霜。","李梦阳《汴京元夕》"] +["苏小门前柳万条,毵毵金线拂平桥。","温庭筠《杨柳八首·其三》"] +["接叶巢莺,平波卷絮,断桥斜日归船。","张炎《高阳台·西湖春感》"] +["轻阴池馆水平桥,一番弄雨花梢。","陈子龙《画堂春·雨中杏花》"] +["行人莫便消魂去,汉渚星桥尚有期。","晏几道《鹧鸪天·梅蕊新妆桂叶眉》"] +["黄尘清水三山下,更变千年如走马。","李贺《梦天》"] +["有熊有罴,有猫有虎,庆既令居,韩姞燕誉。","佚名《韩奕》"] +["琵琶金翠羽,弦上黄莺语。","韦庄《菩萨蛮》"] +["风紧雁行高,无边落木萧萧。","纳兰性德《河渎神·风紧雁行高》"] +["燕子不曾来,小院阴阴雨。","蒋春霖《卜算子·燕子不曾来》"] +["日入群动息,归鸟趋林鸣。","陶渊明《饮酒·其四》"] +["殊方日落玄猿哭,旧国霜前白雁来。","杜甫《九日五首·其一》"] +["汉家旌帜满阴山,不遣胡儿匹马还。","戴叔伦《塞上曲二首·其二》"] +["黄衫飞白马,日日青楼下。","陈克《菩萨蛮·赤阑桥尽香街直》"] +["金络青骢白玉鞍,长鞭紫陌野游盘。","万楚《骢马》"] +["万里云间戍,立马剑门关。","崔与之《水调歌头·题剑阁》"] +["屈盘戏白马,大笑上青山。","李白《登敬亭北二小山余时送客逢崔侍御并登此地》"] +["高馆张灯酒复清,夜钟残月雁归声。","高适《夜别韦司士》"] +["乌丝阑纸娇红篆,历历春星。","纳兰性德《红窗月·燕归花谢》"] +["斜髻娇娥夜卧迟,梨花风静鸟栖枝。","唐寅《美人对月》"] +["鸟飞千白点,日没半红轮。","白居易《彭蠡湖晚归》"] +["天清一雁远,海阔孤帆迟。","李白《送张舍人之江东》"] +["道傍榆荚仍似钱,摘来沽酒君肯否。","岑参《戏问花门酒家翁》"] +["易醉扶头酒,难逢敌手棋。","贺铸《南歌子·疏雨池塘见》"] +["春未来时,酒携不到千岩路。","吴文英《点绛唇·越山见梅》"] +["浅把涓涓酒,深凭送此生。","杜甫《水槛遣心二首》"] +["无计奈情何,且醉金杯酒。","晏几道《生查子·轻匀两脸花》"] +["漠漠萧萧,香冻梨花雨。","吴大有《点绛唇·送李琴泉》"] +["画船儿天边至,酒旗儿风外飐。","张养浩《水仙子·咏江南》"] +["呵笔难临帖,敲床且煮茶。","大须《暮雪》"] +["今日听君歌一曲,暂凭杯酒长精神。","刘禹锡《酬乐天扬州初逢席上见赠》"] +["非学无以广才,非志无以成学。","诸葛亮《诫子书》"] +["梨花最晚又凋零,何事归期无定准。","欧阳修《玉楼春·去时梅萼初凝粉》"] +["人而无仪,不死何为?","佚名《相鼠》"] +["回眸一笑百媚生,六宫粉黛无颜色。","白居易《长恨歌》"] +["不见去年人,泪湿春衫袖。","欧阳修《生查子·元夕》"] +["故国伤心,新亭泪眼,更洒潇潇雨。","王澜《念奴娇·避地溢江书于新亭》"] +["到家应是,童稚牵衣,笑我华颠。","吴激《诉衷情·夜寒茅店不成眠》"] +["永怀愁不寐,松月夜窗虚。","孟浩然《岁暮归南山 / 归故园作 / 归终南山》"] +["梁园日暮乱飞鸦,极目萧条三两家。","岑参《山房春事二首》"] +["伤高怀远几时穷。无物似情浓。","张先《一丛花令·伤高怀远几时穷》"] +["昔日横波目,今成流泪泉。","李白《长相思三首·其二》"] +["阶下青苔与红树,雨中寥落月中愁。","李商隐《端居》"] +["雨歇梧桐泪乍收,遣怀翻自忆从头。","纳兰性德《浣溪沙·雨歇梧桐泪乍收》"] +["立尽黄昏泪几行,一片鸦啼月。","夏完淳《卜算子·秋色到空闺》"] +["便做无情,莫也愁人苦。","朱淑真《蝶恋花·送春》"] +["今日山城对垂泪,伤心不独为悲秋。","李益《上汝州郡楼》"] +["总把春山扫眉黛,不知供得几多愁?","李商隐《代赠二首》"] +["疏星淡月秋千院,愁云恨雨芙蓉面。","张可久《塞鸿秋·春情》"] +["赠远虚盈手,伤离适断肠。","李商隐《十一月中旬至扶风界见梅花》"] +["不枉东风吹客泪。相思难表,梦魂无据,惟有归来是。","欧阳修《青玉案·一年春事都来几》"] +["泪眼倚楼频独语。双燕飞来,陌上相逢否?","冯延巳《鹊踏枝·几日行云何处去》"] +["午窗睡起莺声巧,何处唤春愁?","朱淑真《眼儿媚·风日迟迟弄轻柔》"] +["翠葆参差竹径成。新荷跳雨泪珠倾。曲阑斜转小池亭。","周邦彦《浣沙溪·翠葆参差竹径成》"] +["千年忠义气,日星光。离骚读罢总堪伤。","舒頔《小重山·端午》"] +["佳人相对泣,泪下罗衣湿。","李师中《菩萨蛮·子规啼破城楼月》"] +["残灯明灭枕头欹,谙尽孤眠滋味。","范仲淹《御街行·秋日怀旧》"] +["妾身独自眠,月圆人未圆。","王元鼎《凭栏人·闺怨》"] +["独敲初夜磬,闲倚一枝藤。","李商隐《北青萝》"] +["一觞虽独尽,杯尽壶自倾。","陶渊明《饮酒·其四》"] +["梳洗罢,独倚望江楼。","温庭筠《望江南·梳洗罢》"] +["无言独上西楼,月如钩。","李煜《相见欢·无言独上西楼》"] +["荒烟凉雨助人悲,泪染衣襟不自知。","王安石《送和甫至龙安微雨》"] +["儿女此情同。往事朦胧。湘娥竹上泪痕浓。","辛弃疾《浪淘沙·赋虞美人草》"] +["剧辛乐毅感恩分,输肝剖胆效英才。","李白《行路难三首》"] +["千年史册耻无名,一片丹心报天子。","陆游《金错刀行》"] +["东山老,可堪岁晚,独听桓筝。","叶梦得《八声甘州·寿阳楼八公山作》"] +["星稀河影转,霜重月华孤。","张孝祥《临江仙·试问梅花何处好》"] +["何日平胡虏,良人罢远征。","李白《子夜吴歌·秋歌》"] +["秋逼暗虫通夕响,征衣未寄莫飞霜。","张仲素《秋夜曲》"] +["我自中宵成转侧,忍听湘弦重理。","纳兰性德《金缕曲·亡妇忌日有感》"] +["手携稚子夜归院,月冷空房不见人。","白居易《为薛台悼亡》"] +["余亦赴京国,何当献凯还。","孟浩然《送陈七赴西军》"] +["且凭天子怒,复倚将军雄。","高适《塞下曲》"] +["龙蟠虎踞金陵郡,古来六代豪华盛。","康与之《菩萨蛮令·金陵怀古》"] +["千秋钓舸歌明月,万里沙鸥弄夕阳。","杜牧《西江怀古》"] +["清镜无双影,穷泉有几重。","顾况《酅公合祔挽歌》"] +["可怜青冢已芜没,尚有哀弦留至今。","王安石《明妃曲二首》"] +["玉树歌残犹有曲,锦帆归去已无家。","曾棨《维扬怀古》"] +["过春风十里。尽荠麦青青。","姜夔《扬州慢·淮左名都》"] +["城上风光莺语乱,城下烟波春拍岸。","钱惟演《木兰花·城上风光莺语乱》"] +["沅水通波接武冈,送君不觉有离伤。","王昌龄《送柴侍御》"] +["淡云孤雁远,寒日暮天红。","徐昌图《临江仙·饮散离亭西去》"] +["飞蓬各自远,且尽手中杯。","李白《鲁郡东石门送杜二甫》"] +["尚想旧情怜婢仆,也曾因梦送钱财。","元稹《遣悲怀三首·其二》"] +["何期小会幽欢,变作离情别绪。","柳永《昼夜乐·洞房记得初相遇》"] +["别来几春未还家,玉窗五见樱桃花。","李白《久别离》"] +["别离还有经年客,怅望不如河鼓星。","徐凝《七夕》"] +["棠梨花映白杨树,尽是死生别离处。","白居易《寒食野望吟》"] +["离愁万种,醉乡一夜头白。","施耐庵《念奴娇·天南地北》"] +["何因不归去?淮上有秋山。","韦应物《淮上喜会梁川故人 / 淮上喜会梁州故人》"] +["欲祭疑君在,天涯哭此时。","张籍《没蕃故人》"] +["偶然值林叟,谈笑无还期。","王维《终南别业 / 初至山中 / 入山寄城中故人》"] +["故人何不返,春华复应晚。","柳恽《江南曲》"] +["忽忆故人天际去,计程今日到梁州。","白居易《同李十一醉忆元九》"] +["芳草已云暮,故人殊未来。","韦庄《章台夜思》"] +["寂寞离亭掩,江山此夜寒。","王勃《江亭夜月送别二首》"] +["还怕掩、深院梨花,又作故人清泪。","吴文英《无闷·催雪》"] +["当年紫禁烟花,相逢恨不知音早。","王恽《水龙吟·送焦和之赴西夏行省》"] +["从今后,梦魂千里,夜夜岳阳楼。","徐君宝妻《满庭芳·汉上繁华》"] +["翻被梨花冷看,人生苦恋天涯。","张炎《朝中措·清明时节》"] +["羁鸟恋旧林,池鱼思故渊。","陶渊明《归园田居·其一》"] +["未老莫还乡,还乡须断肠。","韦庄《菩萨蛮·人人尽说江南好》"] +["今春看又过,何日是归年。","杜甫《绝句二首》"] +["胡马依北风,越鸟巢南枝。","佚名《行行重行行》"] +["一晌凭栏人不见,鲛绡掩泪思量遍。","冯延巳《鹊踏枝·梅落繁枝千万片》"] +["青山朝别暮还见,嘶马出门思旧乡。","李颀《送陈章甫》"] +["梦回芳草思依依,天远雁声稀。","李煜《喜迁莺·晓月坠》"] +["玉炉香,红蜡泪,偏照画堂秋思。","温庭筠《更漏子·玉炉香》"] +["一别家山音信杳,百种相思,肠断何时了。","施耐庵《蝶恋花·一别家山音信杳》"] +["夜闻归雁生乡思,病入新年感物华。","欧阳修《戏答元珍》"] +["帝里重清明,人心自愁思。","孟浩然《清明即事》"] +["相逢不尽平生事,春思入琵琶。","刘迎《乌夜啼·离恨远萦杨柳》"] +["自是相思抽不尽,却教风雨怨秋声。","郑板桥《咏芭蕉》"] +["芳草句,碧云辞,低徊闲自思。","佟世南《阮郎归·杏花疏雨洒香堤》"] +["不觉新凉似水,相思两鬓如霜。","刘辰翁《西江月·新秋写兴》"] +["恨君却似江楼月,暂满还亏,暂满还亏,待得团圆是几时?","吕本中《采桑子·恨君不似江楼月》"] +["石壕村里夫妻别,泪比长生殿上多。","袁枚《马嵬》"] +["香炉初上日,瀑水喷成虹。","孟浩然《彭蠡湖中望庐山》"] +["庐山秀出南斗傍,屏风九叠云锦张。","李白《庐山谣寄卢侍御虚舟》"] +["河水洋洋,北流活活。","佚名《硕人》"] +["君宠益娇态,君怜无是非。","王维《西施咏》"] +["问君何事轻离别,一年能几团圞月。","纳兰性德《菩萨蛮·问君何事轻离别》"] +["兽炉沉水烟,翠沼残花片。一行写入相思传。","张可久《塞鸿秋·春情》"] +["君在阴兮影不见,君依光兮妾所愿。","傅玄《车遥遥篇》"] +["那里是清江江上村,香闺里冷落谁瞅问?好一个憔悴的凭栏人。","关汉卿《大德歌·冬》"] +["远与君别者,乃至雁门关。","江淹《古离别》"] +["骊山四顾,阿房一炬,当时奢侈今何处?","张养浩《山坡羊·骊山怀古》"] +["槲叶落山路,枳花明驿墙。","温庭筠《商山早行》"] +["适与野情惬,千山高复低。","梅尧臣《鲁山山行》"] +["一声画角谯门,丰庭新月黄昏,雪里山前水滨。","白朴《天净沙·冬》"] +["啼莺舞燕,小桥流水飞红。","白朴《天净沙·春》"] +["去来江口守空船,绕船月明江水寒。","白居易《琵琶行 / 琵琶引》"] +["戎马关山北,凭轩涕泗流。","杜甫《登岳阳楼》"] +["水何澹澹,山岛竦峙。","曹操《观沧海 / 碣石篇》"] +["初闻征雁已无蝉,百尺楼高水接天。","李商隐《霜月》"] +["知音如不赏,归卧故山秋。","贾岛《题诗后》"] +["碧野朱桥当日事,人不见,水空流。","秦观《江城子·西城杨柳弄春柔》"] +["红树青山日欲斜,长郊草色绿无涯。","欧阳修《丰乐亭游春·其三》"] +["烟尘犯雪岭,鼓角动江城。","杜甫《岁暮》"] +["西风满天雪,何处报人恩。","齐己《剑客》"] +["冻云宵遍岭,素雪晓凝华。","李世民《望雪》"] +["天山雪后海风寒,横笛偏吹行路难。","李益《从军北征》"] +["晴云满户团倾盖,秋水浮阶溜决渠。","杜甫《柏学士茅屋》"] +["早是他乡值早秋,江亭明月带江流。","王勃《秋江送别二首》"] +["秋风起兮木叶飞,吴江水兮鲈正肥。","张翰《思吴江歌》"] +["罗襟湿未干,又是凄凉雪。","张淑芳《满路花·冬》"] +["行人日暮少,风雪乱山深。","孔平仲《寄内·试说途中景》"] +["梅花散彩向空山,雪花随意穿帘幕。","王旭《踏莎行·雪中看梅花》"] +["细看不是雪无香,天风吹得香零落。","王旭《踏莎行·雪中看梅花》"] +["洞庭之东江水西,帘旌不动夕阳迟。","陈与义《登岳阳楼》"] +["今日江头两三树,可怜和叶度残春。","元稹《离思五首》"] +["岁岁金河复玉关,朝朝马策与刀环。","柳中庸《征人怨 / 征怨》"] +["醉不成欢惨将别,别时茫茫江浸月。","白居易《琵琶行 / 琵琶引》"] +["牵衣顿足拦道哭,哭声直上干云霄。","杜甫《兵车行》"] +["流星透疏木,走月逆行云。","贾岛《宿山寺》"] +["彩舟云淡,星河鹭起,画图难足。","王安石《桂枝香·登临送目》"] +["凭高眺远,见长空万里,云无留迹。","苏轼《念奴娇·中秋》"] +["浮云柳絮无根蒂,天地阔远随飞扬。","韩愈《听颖师弹琴》"] +["龙嘘气成云,云固弗灵于龙也。","韩愈《杂说一·龙说》"] +["土膏欲动雨频催,万草千花一饷开;","范成大《春日田园杂兴》"] +["沙场烽火连胡月,海畔云山拥蓟城。","祖咏《望蓟门》"] +["归鸿声断残云碧。背窗雪落炉烟直。","李清照《菩萨蛮·归鸿声断残云碧》"] +["坡谓西湖,正如西子,浓抹淡妆临镜台。","刘过《六州歌头·寄稼轩承旨》"] +["月落沙平江似练。望尽芦花无雁。","张炎《清平乐·候蛩凄断》"] +["沧江好烟月,门系钓鱼船。","杜牧《旅宿》"] +["借与门前磐石坐,柳阴亭午正风凉。","范成大《夏日田园杂兴》"] +["日月照之何不及此?惟有北风号怒天上来。","李白《北风行》"] +["东风动百物,草木尽欲言。","李白《长歌行》"] +["袅袅兮秋风,洞庭波兮木叶下。","屈原《九歌·湘夫人》"] +["金风细细。叶叶梧桐坠。","晏殊《清平乐·金风细细》"] +["昨夜狂风度,吹折江头树。","李白《长干行二首》"] +["舟遥遥以轻飏,风飘飘而吹衣。","陶渊明《归去来兮辞·并序》"] +["顺风而呼,声非加疾也,而闻者彰。","荀子《劝学》"] +["世人解听不解赏,长飙风中自来往。","李颀《听安万善吹觱篥歌》"] +["寂历秋江渔火稀,起看残月映林微。","汤显祖《江宿》"] +["银箭金壶漏水多,起看秋月坠江波。","李白《乌栖曲》"] +["回廊远砌生秋草,梦魂千里青门道。","冯延巳《菩萨蛮·回廊远砌生秋草》"] +["最爱芦花经雨后,一蓬烟火饭鱼船。","林逋《咏秋江》"] +["白云映水摇空城,白露垂珠滴秋月。","李白《金陵城西楼月下吟》"] +["寒眼乱空阔,客意不胜秋。","杨炎正《水调歌头·登多景楼》"] +["雨过一蝉噪,飘萧松桂秋。","杜牧《题扬州禅智寺》"] +["枕簟溪堂冷欲秋。断云依水晚来收。","辛弃疾《鹧鸪天·鹅湖归病起作》"] +["南陵水面漫悠悠,风紧云轻欲变秋。","杜牧《南陵道中 / 寄远》"] +["晓迎秋露一枝新,不占园中最上春。","杜牧《紫薇花》"] +["只有一枝梧叶,不知多少秋声。","张炎《清平乐·候蛩凄断》"] +["寒更承夜永,凉夕向秋澄。","骆宾王《送别》"] +["小溪清水平如镜,一叶飞来浪细生。","徐玑《秋行》"] +["断虹霁雨,净秋空,山染修眉新绿。","黄庭坚《念奴娇·断虹霁雨》"] +["弓背霞明剑照霜,秋风走马出咸阳。","令狐楚《少年行四首·其三》"] +["夜雨做成秋,恰上心头。","纳兰性德《浪淘沙·夜雨做成秋》"] +["昨夜风兼雨,帘帏飒飒秋声。","李煜《乌夜啼·昨夜风兼雨》"] +["阴生古苔绿,色染秋烟碧。","李白《南轩松》"] +["昨夜秋风入汉关,朔云边月满西山。","严武《军城早秋》"] +["转眼葵肌初绣,又红欹栏角。","陈维崧《好事近·夏日史遽庵先生招饮即用先生喜余归自吴阊过访原韵》"] +["风老莺雏,雨肥梅子,午阴嘉树清圆。","周邦彦《满庭芳·夏日溧水无想山作》"] +["涉江玩秋水,爱此红蕖鲜。","李白《折荷有赠》"] +["春云吹散湘帘雨,絮黏蝴蝶飞还住。","纳兰性德《菩萨蛮·春云吹散湘帘雨》"] +["游人不管春将老,来往亭前踏落花。","欧阳修《丰乐亭游春·其三》"] +["林外鸣鸠春雨歇,屋头初日杏花繁。","欧阳修《田家》"] +["荆吴相接水为乡,君去春江正淼茫。","孟浩然《送杜十四之江南 / 送杜晃进士之东吴》"] +["解鞍欹枕绿杨桥,杜宇一声春晓。","苏轼《西江月·顷在黄州》"] +["草秀故春色,梅艳昔年妆。","李世民《元日》"] +["酒浓春入梦,窗破月寻人。","毛滂《临江仙·都城元夕》"] +["菀菀黄柳丝,濛濛杂花垂。","常建《春词二首》"] +["飞雪带春风,裴回乱绕空。","刘方平《春雪》"] +["三月残花落更开,小檐日日燕飞来。","王令《送春》"] +["风柔日薄春犹早。夹衫乍著心情好。","李清照《菩萨蛮·风柔日薄春犹早》"] +["若到江南赶上春,千万和春住。","王观《卜算子·送鲍浩然之浙东》"] +["风回小院庭芜绿,柳眼春相续。","李煜《虞美人·风回小院庭芜绿》"] +["归燕识故巢,旧人看新历。","王维《春中田园作》"] +["微雨霭芳原,春鸠鸣何处。","韦应物《东郊》"] +["昆仑之高有积雪,蓬莱之远常遗寒。","王令《暑旱苦热》"] +["镜湖三百里,菡萏发荷花。","李白《子夜吴歌·夏歌》"] +["疏懒意何长,春风花草香。","黄庭坚《菩萨蛮·半烟半雨溪桥畔》"] +["十日春寒不出门,不知江柳已摇村。","苏轼《正月二十日往岐亭郡人潘古郭三人送余于女王城东禅庄院》"] +["一棹碧涛春水路。过尽晓莺啼处。","晏几道《清平乐·留人不住》"] +["春风倚棹阖闾城,水国春寒阴复晴。","刘长卿《别严士元》"] +["忆年十五心尚孩,健如黄犊走复来。","杜甫《百忧集行》"] +["朱颜空自改,向年年、芳意长新。","韩缜《凤箫吟·锁离愁》"] +["命随年欲尽,身与世俱忘;","文天祥《除夜》"] +["黄金燃桂尽,壮志逐年衰。","孟浩然《秦中感秋寄远上人 / 秦中寄远上人》"] +["泥落画梁空,梦想青春语。","吴文英《生查子·秋社》"] +["携来百侣曾游,忆往昔峥嵘岁月稠。","毛泽东《沁园春·长沙》"] +["不知天上宫阙,今夕是何年。","苏轼《水调歌头·丙辰中秋》"] +["欲系青春,少住春还去。","朱淑真《蝶恋花·送春》"] +["怡然敬父执,问我来何方?问答乃未已,驱儿罗酒浆。","杜甫《赠卫八处士》"] +["妾本秦罗敷,玉颜艳名都。","李白《陌上桑》"] +["父耕原上田,子劚山下荒。","聂夷中《田家》"] +["偶应非熊兆,尊为帝者师。","刘基《题太公钓渭图》"] +["庭花蒙蒙水泠泠,小儿啼索树上莺。","贯休《春晚书山家屋壁二首》"] +["骑牛远远过前村,短笛横吹隔陇闻。","黄庭坚《牧童诗》"] +["小童疑是有村客,急向柴门去却关。","崔道融《溪居即事》"] +["稚子金盆脱晓冰,彩丝穿取当银铮。","杨万里《稚子弄冰》"] +["竹竿有甘苦,我爱抱苦节。","孟郊《苦寒吟》"] +["上窗风动竹,月微明。","吕渭老《小重山·七夕病中》"] +["迸箨分苦节,轻筠抱虚心。","柳宗元《巽公院五咏·苦竹桥》"] +["闻说东风亦多情,被竹外、香留住。","史达祖《留春令·咏梅花》"] +["君看萧萧只数叶,满堂风雨不胜寒。","李东阳《柯敬仲墨竹》"] +["竹怜新雨后,山爱夕阳时。","钱起《谷口书斋寄杨补阙》"] +["地冷叶先尽,谷寒云不行。","李白《冬日归旧山》"] +["天涯芳草迷归路,病叶还禁一夜霜。","王夫之《初度口占·辛丑》"] +["桂花香雾冷,梧叶西风影。","高观国《菩萨蛮·何须急管吹云暝》"] +["红粉当垆弱柳垂,金花腊酒解酴醿。","贾至《春思二首》"] +["粉色全无饥色加,岂知人世有荣华。","杜荀鹤《蚕妇》"] +["星点点,月团团。倒流河汉入杯盘。","刘著《鹧鸪天·雪照山城玉指寒》"] +["吏呼一何怒,妇啼一何苦。","杜甫《石壕吏》"] +["谢公最小偏怜女,自嫁黔娄百事乖。","元稹《遣悲怀三首·其一》"] +["杨柳青青著地垂,杨花漫漫搅天飞。","佚名《送别诗》"] +["飞絮濛濛。垂柳阑干尽日风。","欧阳修《采桑子·群芳过后西湖好》"] +["榆柳荫后檐,桃李罗堂前。","陶渊明《归园田居·其一》"] +["解把飞花蒙日月,不知天地有清霜。","曾巩《咏柳·乱条犹未变初黄》"] +["柳丝长,桃叶小。深院断无人到。","晏几道《更漏子·柳丝长》"] +["离愁不管人飘泊。年年孤负黄花约。","黄机《忆秦娥·秋萧索》"] +["梅定妒,菊应羞。","李清照《鹧鸪天·桂花》"] +["九日龙山饮,黄花笑逐臣。","李白《九日龙山饮》"] +["芙蓉金菊斗馨香。天气欲重阳。","晏殊《诉衷情·芙蓉金菊斗馨香》"] +["欲知怅别心易苦,向暮春风杨柳丝。","李白《下途归石门旧居》"] +["相思黄叶落,白露湿青苔。","李白《长相思三首》"] +["烟水初销见万家,东风吹柳万条斜。","窦巩《襄阳寒食寄宇文籍》"] +["燕忙莺懒芳残,正堤上、柳花飘坠。","章楶《水龙吟·燕忙莺懒芳残》"] +["临砌影,寒香乱、冻梅藏韵。","吴文英《花犯·郭希道送水仙索赋》"] +["水溢芙蓉沼,花飞桃李蹊。","薛道衡《昔昔盐》"] +["仙人琪树白无色,王母桃花小不香。","白居易《牡丹芳》"] +["春来遍是桃花水,不辨仙源何处寻。","王维《桃源行》"] +["非鬼亦非仙,一曲桃花水。","辛弃疾《生查子·独游雨岩》"] +["夔府孤城落日斜,每依北斗望京华。","杜甫《秋兴八首》"] +["春日载阳,有鸣仓庚。","佚名《七月》"] +["日射纱窗风撼扉,香罗拭手春事违。","李商隐《日射》"] +["昨夜风开露井桃,未央前殿月轮高。","王昌龄《春宫曲》"] +["渌水净素月,月明白鹭飞。","李白《秋浦歌十七首》"] +["撩乱边愁听不尽,高高秋月照长城。","王昌龄《从军行七首》"] +["胡瓶落膊紫薄汗,碎叶城西秋月团。","王昌龄《从军行七首》"] +["骝马新跨白玉鞍,战罢沙场月色寒。","王昌龄《出塞二首》"] +["醉月频中圣,迷花不事君。","李白《赠孟浩然》"] +["明月何皎皎,照我罗床帏。","佚名《明月何皎皎》"] +["月华如练,长是人千里。","范仲淹《御街行·秋日怀旧》"] +["寒月悲笳,万里西风瀚海沙。","纳兰性德《采桑子·塞上咏雪花》"] +["黄昏独倚朱阑。西南新月眉弯。","冯延巳《清平乐·雨晴烟晚》"] +["雨过横塘水满堤,乱山高下路东西。","曾巩《城南·雨过横塘水满堤》"] +["丝丝杨柳丝丝雨。春在溟濛处。","蒋捷《虞美人·梳楼》"] +["淅淅西风淡淡烟,几点疏疏雨。","葛立方《卜算子·席间再作》"] +["乱碧萋萋,雨后江天晓。","梅尧臣《苏幕遮·草》"] +["雨声飕飕催早寒,胡雁翅湿高飞难。","杜甫《秋雨叹三首》"] +["微雨如酥,草色遥看近却无。","苏轼《减字木兰花·莺初解语》"] +["来扫千山雪,归留万国花。","袁枚《春风》"] +["冬宜密雪,有碎玉声。","王禹偁《黄冈竹楼记》"] +["余拏一小舟,拥毳衣炉火,独往湖心亭看雪。","张岱《湖心亭看雪》"] +["夜来城外一尺雪,晓驾炭车辗冰辙。","白居易《卖炭翁》"] +["风起。雪飞炎海变清凉。","苏轼《定风波·南海归赠王定国侍人寓娘》"] +["花明玉关雪,叶暖金窗烟。","李白《折杨柳》"] +["气蒸云梦泽,波撼岳阳城。","孟浩然《望洞庭湖赠张丞相 / 临洞庭湖赠张丞相》"] +["天上白玉京,十二楼五城。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["天若不爱酒,酒星不在天。","李白《月下独酌四首》"] +["杨花落尽子规啼,闻道龙标过五溪。","李白《闻王昌龄左迁龙标遥有此寄》"] +["李白乘舟将欲行,忽闻岸上踏歌声。","李白《赠汪伦》"] +["暮从碧山下,山月随人归。","李白《下终南山过斛斯山人宿置酒》"] +["碧云天,黄叶地。","范仲淹《苏幕遮·怀旧》"] +["如梦,如梦,残月落花烟重。","李存勖《如梦令·曾宴桃源深洞》"] +["神龟虽寿,犹有竟时。","曹操《龟虽寿》"] +["天下三分明月夜,二分无赖是扬州。","徐凝《忆扬州》"] +["墙头马上遥相顾,一见知君即断肠。","白居易《井底引银瓶·止淫奔也》"] +["我未成名卿未嫁,可能俱是不如人。","罗隐《赠妓云英 / 嘲钟陵妓云英》"] +["可怜无定河边骨,犹是春闺梦里人。","陈陶《陇西行四首》"] +["虚负凌云万丈才,一生襟抱未曾开。","崔珏《哭李商隐》"] +["世界微尘里,吾宁爱与憎。","李商隐《北青萝》"] +["可怜夜半虚前席,不问苍生问鬼神。","李商隐《贾生》"] +["更深月色半人家,北斗阑干南斗斜。","刘方平《月夜》"] +["岁月人间促,烟霞此地多。","朱放《题竹林寺》"] +["祸不妄至,福不徒来。","《史记·龟策列传》"] +["须作一生拚,尽君今日欢。","牛峤《菩萨蛮·玉炉冰簟鸳鸯锦》"] +["一去二三里,烟村四五家。","邵雍《山村咏怀》"] +["一生一代一双人,争教两处销魂。","纳兰性德《画堂春·一生一代一双人》"] +["人人尽说江南好,游人只合江南老。","韦庄《菩萨蛮·人人尽说江南好》"] +["应是天仙狂醉,乱把白云揉碎。","李白《清平乐·画堂晨起》"] +["杏花疏影里,吹笛到天明。","陈与义《临江仙·夜登小阁忆洛中旧游》"] +["人生无根蒂,飘如陌上尘。","陶渊明《杂诗》"] +["只恐夜深花睡去,故烧高烛照红妆。","苏轼《海棠》"] +["天若有情天亦老,摇摇幽恨难禁。","孙洙《河满子·秋怨》"] +["汉恩自浅胡恩深,人生乐在相知心。","王安石《明妃曲二首》"] +["不要人夸颜色好,只留清气满乾坤。","王冕《墨梅》"] +["等闲变却故人心,却道故人心易变。","纳兰性德《木兰花·拟古决绝词柬友》"] +["结庐在人境,而无车马喧。","陶渊明《饮酒·其五》"] +["一声声,一更更。","万俟咏《长相思·雨》"] +["旧时茅店社林边,路转溪桥忽见。","辛弃疾《西江月·夜行黄沙道中》"] +["欲买桂花同载酒,终不似,少年游。","刘过《唐多令·芦叶满汀洲》"] +["梧桐半死清霜后,头白鸳鸯失伴飞。","贺铸《半死桐·重过阊门万事非》"] +["妙手写徽真,水剪双眸点绛唇。","秦观《南乡子·妙手写徽真》"] +["常羡人间琢玉郎,天应乞与点酥娘。","苏轼《定风波·南海归赠王定国侍人寓娘》"] +["花向老人头上笑,羞羞,白发簪花不解愁。","黄庭坚《南乡子·诸将说封侯》"] +["兴,百姓苦;亡,百姓苦!","张养浩《山坡羊·潼关怀古》"] +["惆怅东栏一株雪,人生看得几清明。","苏轼《东栏梨花》"] +["万树江边杏,新开一夜风。","王涯《游春曲二首·其一》"] +["心灰尽、有发未全僧。","纳兰性德《忆江南·宿双林禅院有感》"] +["人生天地之间,若白驹之过隙,忽然而已。","《庄子·外篇·知北游》"] +["尾生与女子期于梁下,女子不来,水至不去,抱梁柱而死。","《庄子·杂篇·盗跖》"] +["弱水三千,我只取一瓢饮。","《红楼梦·第九十一回》"] +["玄都观里桃千树,尽是刘郎去后栽。","刘禹锡《元和十年自朗州至京戏赠看花诸君子 / 玄都观桃花》"] +["经事还谙事,阅人如阅川。","刘禹锡《酬乐天咏老见示》"] +["长恨人心不如水,等闲平地起波澜。","刘禹锡《竹枝词九首》"] +["复恐匆匆说不尽,行人临发又开封。","张籍《秋思》"] +["娉娉袅袅十三余,豆蔻梢头二月初。","杜牧《赠别二首》"] +["上穷碧落下黄泉,两处茫茫皆不见。","白居易《长恨歌》"] +["折花逢驿使,寄与陇头人。","陆凯《赠范晔诗》"] +["花非花,雾非雾。","白居易《花非花》"] +["茕茕白兔,东走西顾。","佚名《古艳歌》"] +["衣不如新,人不如故。","佚名《古艳歌》"] +["同心而离居,忧伤以终老。","佚名《涉江采芙蓉》"] +["生年不满百,常怀千岁忧。","佚名《生年不满百》"] +["春色三分,二分尘土,一分流水。","苏轼《水龙吟·次韵章质夫杨花词》"] +["冬夜夜寒冰合井,画堂明月侵帏。","苏轼《临江仙·冬夜夜寒冰合井》"] +["莫听穿林打叶声,何妨吟啸且徐行。","苏轼《定风波·莫听穿林打叶声》"] +["我本不弃世,世人自弃我。","李白《送蔡山人》"] +["蓬莱文章建安骨,中间小谢又清发。","李白《宣州谢脁楼饯别校书叔云》"] +["朝回日日典春衣,每日江头尽醉归。","杜甫《曲江二首》"] +["托身白刃里,杀人红尘中。","李白《赠从兄襄阳少府皓》"] +["积雨空林烟火迟,蒸藜炊黍饷东菑。","王维《积雨辋川庄作 / 秋归辋川庄作》"] +["十四为君妇,羞颜未尝开。","李白《长干行二首》"] +["事了拂衣去,深藏身与名。","李白《侠客行》"] +["早知潮有信,嫁与弄潮儿。","李益《江南曲》"] +["冬夜兮陶陶,雨雪兮冥冥。","王逸《九思》"] +["今年春浅腊侵年。冰雪破春妍。","苏轼《一丛花·今年春浅侵年》"] +["雪似梅花,梅花似雪。","吕本中《踏莎行·雪似梅花》"] +["空对着,山中高士晶莹雪;","曹雪芹《终身误》"] +["看雪飞、苹底芦梢,未如鬓白。","吴文英《瑞鹤仙·秋感》"] +["北风其凉,雨雪其雱。","佚名《北风》"] +["雨后轻寒犹未放。春愁酒病成惆怅。","欧阳修《蝶恋花·面旋落花风荡漾》"] +["透内阁香风阵阵,掩重门暮雨纷纷。","王实甫《十二月过尧民歌·别情》"] +["岸雨过城头,黄鹂上戍楼。","岑参《武威春暮闻宇文判官西使还已到晋昌》"] +["瀚海百重波,阴山千里雪。","李世民《饮马长城窟行》"] +["暮雨相呼失,寒塘欲下迟。","崔涂《孤雁二首·其二》"] +["豆雨声来,中间夹带风声。","蒋捷《声声慢·秋声》"] +["梅花南北路,风雨湿征衣。","文天祥《南安军》"] +["夜来雨横与风狂,断送西园满地香。","阿鲁威《湘妃怨·夜来雨横与风狂》"] +["青冥浩荡不见底,日月照耀金银台。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["疏条交映,有时见日。","吴均《与朱元思书》"] +["云来气接巫峡长,月出寒通雪山白。","杜甫《古柏行》"] +["寒日萧萧上琐窗,梧桐应恨夜来霜。","李清照《鹧鸪天·寒日萧萧上琐窗》"] +["三五明月满,四五蟾兔缺。","佚名《孟冬寒气至》"] +["桂棹兮兰枻,斵冰兮积雪;","屈原《九歌》"] +["两岸桃花烘日出,四围高柳到天垂。","姜彧《浣溪沙·山滴岚光水拍堤》"] +["记得别伊时,桃花柳万丝。","纳兰性德《菩萨蛮·新寒中酒敲窗雨》"] +["无限春风来海上。便与春工。染得桃红似肉红。","苏轼《减字木兰花·立春》"] +["小桃无主自开花,烟草茫茫带晓鸦。","戴复古《淮村兵后》"] +["洞门高阁霭馀辉,桃李阴阴柳絮飞。","王维《酬郭给事 / 赠郭给事》"] +["春水迷天,桃花浪、几番风恶。","张元干《满江红·自豫章阻风吴城山作》"] +["吴兴才人怨春风,桃花满陌千里红。","李贺《送沈亚之歌·并序》"] +["昔日芙蓉花,今成断根草。","李白《妾薄命》"] +["御柳如丝映九重,凤凰窗映绣芙蓉。","温庭筠《杨柳枝》"] +["会桃花之芳园,序天伦之乐事。","李白《春夜宴桃李园序 / 春夜宴从弟桃花园序》"] +["山泉散漫绕阶流,万树桃花映小楼。","元稹《离思五首》"] +["兰溪三日桃花雨,半夜鲤鱼来上滩。","戴叔伦《兰溪棹歌》"] +["一番桃李花开尽,惟有青青草色齐。","曾巩《城南·雨过横塘水满堤》"] +["越梅半拆轻寒里,冰清淡薄笼蓝水。","和凝《菩萨蛮·越梅半拆轻寒里》"] +["醉折残梅一两枝,不妨桃李自逢时。","陆游《落梅》"] +["梅以曲为美,直则无姿;","龚自珍《病梅馆记》"] +["匝路亭亭艳,非时裛裛香。","李商隐《十一月中旬至扶风界见梅花》"] +["塞北梅花羌笛吹,淮南桂树小山词。","刘禹锡《杨柳枝词九首·其一》"] +["满街杨柳绿丝烟,画出清明二月天。","韦庄《丙辰年鄜州遇寒食城外醉吟五首》"] +["魂来枫叶青,魂返关塞黑。","杜甫《梦李白二首·其一》"] +["两行疏柳,一丝残照,万点鸦栖。","刘基《眼儿媚·秋思》"] +["头上尽教添白发,鬓边不可无黄菊。","宋江《满江红·喜遇重阳》"] +["柳花深巷午鸡声,桑叶尖新绿未成。","范成大《春日田园杂兴》"] +["柳条将舒未舒,柔梢披风,麦田浅鬣寸许。","袁宏道《满井游记》"] +["妇姑相唤浴蚕去,闲看中庭栀子花。","王建《雨过山村》"] +["波澜誓不起,妾心古井水。","孟郊《列女操 / 烈女操》"] +["团扇不摇风自举,盈盈翠竹,纤纤白苎,不受些儿暑。","文征明《青玉案·庭下石榴花乱吐》"] +["先生名利比尘灰,绿竹青松手自栽。","王汝舟《咏归堂隐鳞洞》"] +["竹树带飞岚,荇藻俱明丽。","刘道著《湘江秋晓》"] +["世态便如翻覆雨,妾身元是分明月。","文天祥《满江红·和王夫人满江红韵以庶几後山妾薄命之意》"] +["天子居未央,妾侍卷衣裳。","李白《秦女卷衣》"] +["淡黄杨柳暗栖鸦。玉人和月摘梅花。","贺铸《减字浣溪沙·楼角初销一缕霞》"] +["何时诏此金钱会,暂醉佳人锦瑟旁。","杜甫《曲江对雨》"] +["昨日春如,十三女儿学绣。","辛弃疾《粉蝶儿·和赵晋臣敷文赋落花》"] +["朝为越溪女,暮作吴宫妃。","王维《西施咏》"] +["秋花冒绿水,密叶罗青烟。","李白《古风·其五十九》"] +["落木萧萧,琉璃叶下琼葩吐。","王十朋《点绛唇·素香丁香》"] +["枝枝相纠结,叶叶竞飘扬。","李白《古意》"] +["莲香隔浦渡,荷叶满江鲜。","刘孝威《采莲曲·金桨木兰船》"] +["黄叶覆溪桥,荒村唯古木。","柳宗元《秋晓行南谷经荒村》"] +["春云不变阳关雪,桑叶先知胡地秋。","李昂《从军行》"] +["竹径通幽处,禅房花木深。","常建《题破山寺后禅院》"] +["西风稍急喧窗竹,停又续,腻脸悬双玉。","阎选《河传·秋雨》"] +["袨服华妆着处逢,六街灯火闹儿童。","元好问《京都元夕》"] +["花落家童未扫,莺啼山客犹眠。","王维《田园乐七首·其六》"] +["厚禄故人书断绝,恒饥稚子色凄凉。","杜甫《狂夫》"] +["爱向竹栏骑竹马,懒于金地聚金沙。","金地藏《送童子下山》"] +["怪见溪童出门望,雀声先我到山家。","刘因《山家·马蹄踏水乱明霞》"] +["见人初解语呕哑,不肯归眠恋小车。","韦庄《与小女》"] +["童子柳阴眠正着,一牛吃过柳阴西。","杨万里《桑茶坑道中》"] +["笑绿鬟邻女,倚窗犹唱,夕阳西下。","蒋捷《女冠子·元夕》"] +["百二禁兵辞象阙,三千宫女下龙舟。","许浑《汴河亭》"] +["白发悲明镜,青春换敝裘。","岑参《武威春暮闻宇文判官西使还已到晋昌》"] +["十五入汉宫,花颜笑春红。","李白《怨歌行》"] +["群峭碧摩天,逍遥不记年。","李白《寻雍尊师隐居》"] +["明年岂无年,心事恐蹉跎。","苏轼《守岁》"] +["忆昔西池池上饮,年年多少欢娱。","晁冲之《临江仙·忆昔西池池上饮》"] +["可惜流年,忧愁风雨,树犹如此!","辛弃疾《水龙吟·登建康赏心亭》"] +["已忍伶俜十年事,强移栖息一枝安。","杜甫《宿府》"] +["凄凉宝剑篇,羁泊欲穷年。","李商隐《风雨》"] +["池上柳依依,柳边人掩扉。","左纬《春晚》"] +["金鞍玉勒寻芳客,未信我庐别有春。","于谦《观书》"] +["任是春风吹不展。困倚危楼。","秦观《减字木兰花·天涯旧恨》"] +["清露晨流,新桐初引,多少游春意。","李清照《念奴娇·春情》"] +["津桥春水浸红霞,烟柳风丝拂岸斜。","雍陶《天津桥望春》"] +["糁径杨花铺白毡,点溪荷叶叠青钱。","杜甫《绝句漫兴九首·其七》"] +["松下茅亭五月凉,汀沙云树晚苍苍。","戴叔伦《题稚川山水》"] +["晓寒料峭尚欺人,春态苗条先到柳。","毛滂《玉楼春·己卯岁元日》"] +["砌下落花风起,罗衣特地春寒。","冯延巳《清平乐·雨晴烟晚》"] +["野田春水碧于镜,人影渡傍鸥不惊。","汪藻《春日·一春略无十日晴》"] +["心心视春草,畏向阶前生。","王维《杂诗三首 / 杂咏三首》"] +["满地残红宫锦污,昨夜南园风雨。","王安国《清平乐·春晚》"] +["绿池芳草满晴波,春色都从雨里过。","高启《春暮西园》"] +["长堤春水绿悠悠,畎入漳河一道流。","王之涣《宴词》"] +["阴阴溪曲绿交加,小雨翻萍上浅沙。","晁冲之《春日·阴阴溪曲绿交加》"] +["南浦春来绿一川,石桥朱塔两依然。","范成大《横塘·南浦春来绿一川》"] +["明朝游上苑,火速报春知。","武则天《腊日宣诏幸上苑 / 催花诗》"] +["芳原绿野恣行事,春入遥山碧四围。","程颢《郊行即事》"] +["游女带花偎伴笑,争窈窕,竞折团荷遮晚照。","李珣《南乡子·乘彩舫》"] +["手弄生绡白团扇,扇手一时似玉。","苏轼《贺新郎·夏景》"] +["携手藕花湖上路。一霎黄梅细雨。","朱淑真《清平乐·夏日游湖》"] +["攀荷弄其珠,荡漾不成圆。","李白《折荷有赠》"] +["夏条绿已密,朱萼缀明鲜。","韦应物《夏花明》"] +["香汗薄衫凉,凉衫薄汗香。","苏轼《菩萨蛮·回文夏闺怨》"] +["南浦凄凄别,西风袅袅秋。","白居易《南浦别》"] +["登舟望秋月,空忆谢将军。","李白《夜泊牛渚怀古》"] +["天秋月又满,城阙夜千重。","戴叔伦《客夜与故人偶集 / 江乡故人偶集客舍》"] +["秋空明月悬,光彩露沾湿。","孟浩然《秋宵月下有怀》"] +["秋风万里芙蓉国,暮雨千家薜荔村。","谭用之《秋宿湘江遇雨》"] +["鸿雁几时到,江湖秋水多。","杜甫《天末怀李白》"] +["秋容浅淡映重门,七节攒成雪满盆。","曹雪芹《咏白海棠》"] +["秋色到空闺,夜扫梧桐叶。","夏完淳《卜算子·秋色到空闺》"] +["故乡篱下菊,今日几花开。","江总《于长安归还扬州九月九日行薇山亭赋韵 / 长安九日诗》"] +["雨过月华生,冷彻鸳鸯浦。","柳永《甘草子·秋暮》"] +["细数十年事,十处过中秋。","范成大《水调歌头·细数十年事》"] +["十轮霜影转庭梧,此夕羁人独向隅。","晏殊《中秋月》"] +["竹叶于人既无分,菊花从此不须开。","杜甫《九日五首·其一》"] +["镜水夜来秋月,如雪。","温庭筠《荷叶杯·镜水夜来秋月》"] +["是天外空汗漫,但长风、浩浩送中秋。","辛弃疾《木兰花慢·中秋饮酒将旦客谓前人诗词有赋待月无送月者因用天问体赋》"] +["客睡何曾著,秋天不肯明。","杜甫《客夜》"] +["白雁乱飞秋似雪,清露生凉夜。","吴西逸《清江引·秋居》"] +["黄河水绕汉宫墙,河上秋风雁几行。","李梦阳《秋望》"] +["求得人间成小会,试把金尊傍菊丛。","晏殊《破阵子·燕子欲归时节》"] +["流星飞玉弹,宝剑落秋霜。","张玉娘《从军行》"] +["明朝万一西风动,争向朱颜不耐秋。","晏几道《鹧鸪天·守得莲开结伴游》"] +["清风徐来,水波不兴。","苏轼《前赤壁赋》"] +["醉后凉风起,吹人舞袖回。","李白《与夏十二登岳阳楼》"] +["飒飒西风满院栽,蕊寒香冷蝶难来。","黄巢《题菊花》"] +["岭树重遮千里目,江流曲似九回肠。","柳宗元《登柳州城楼寄漳汀封连四州》"] +["天势围平野,河流入断山。","畅当《登鹳雀楼》"] +["黄河西来决昆仑,咆哮万里触龙门。","李白《公无渡河》"] +["人间更有风涛险,翻说黄河是畏途。","宋琬《渡黄河》"] +["黄河万里触山动,盘涡毂转秦地雷。","李白《西岳云台歌送丹丘子》"] +["浮云遮月不分明,谁挽长江一洗放天青?","董士锡《虞美人·韶华争肯偎人住》"] +["长江千里,烟淡水云阔。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["惆怅南朝事,长江独至今。","刘长卿《秋日登吴公台上寺远眺》"] +["还与去年人,共藉西湖草。","苏轼《卜算子·感旧》"] +["春衫犹是,小蛮针线,曾湿西湖雨。","苏轼《青玉案·送伯固归吴中》"] +["东风渐绿西湖柳,雁已还、人未南归。","周密《高阳台·送陈君衡被召》"] +["坐看黑云衔猛雨,喷洒前山此独晴。","崔道融《溪上遇雨二首》"] +["四月南风大麦黄,枣花未落桐叶长。","李颀《送陈章甫》"] +["湖阔兼云雾,楼孤属晚晴。","杜甫《陪裴使君登岳阳楼》"] +["多谢浣纱人未折,雨中留得盖鸳鸯。","郑谷《莲叶》"] +["风雨端阳生晦冥,汨罗无处吊英灵。","贝琼《已酉端午》"] +["秋娘渡与泰娘桥,风又飘飘,雨又萧萧。","蒋捷《一剪梅·舟过吴江》"] +["风驱急雨洒高城,云压轻雷殷地声。","刘基《五月十九日大雨》"] +["回首暮云远,飞絮搅青冥。","苏轼《水调歌头·昵昵儿女语》"] +["野客预知农事好,三冬瑞雪未全消。","戴复古《除夜》"] +["重唱梅边新度曲,催发寒梢冻蕊。","吴文英《金缕歌·陪履斋先生沧浪看梅》"] +["千尺阴崖尘不到,惟有层冰积雪。","辛弃疾《贺新郎·用前韵送杜叔高》"] +["江上小堂巢翡翠,苑边高冢卧麒麟。","杜甫《曲江二首》"] +["岁云暮矣多北风,潇湘洞庭白雪中。","杜甫《岁晏行》"] +["典尽客衣三尺雪,炼精诗句一头霜。","王建《维扬冬末寄幕中二从事》"] +["无端听画角,枕畔红冰薄。","纳兰性德《菩萨蛮·朔风吹散三更雪》"] +["大雪北风催,家家贫白屋。","环山樵《薛宝钗·雪竹》"] +["将军玉帐貂鼠衣,手持酒杯看雪飞。","刘基《北风行》"] +["三日柴门拥不开,阶平庭满白皑皑。","韩愈《酬王二十舍人雪中见寄》"] +["霜威出塞早,云色渡河秋。","李白《太原早秋》"] +["栖鸦流水点秋光,爱此萧疏树几行。","纪映淮《咏秋柳》"] +["芜然蕙草暮,飒尔凉风吹。","李白《秋思》"] +["中秋月。月到中秋偏皎洁。","徐有贞《中秋月·中秋月》"] +["乱云低薄暮,急雪舞回风。","杜甫《对雪》"] +["岸容待腊将舒柳,山意冲寒欲放梅。","杜甫《小至》"] +["最爱东山晴后雪,软红光里涌银山。","杨万里《最爱东山晴后雪》"] +["夜寒不近流苏,只怜他、后庭梅瘦。","毛滂《上林春令·十一月三十日见雪》"] +["北风吹雪四更初,嘉瑞天教及岁除。","陆游《除夜雪》"] +["湖清霜镜晓,涛白雪山来。","李白《送友人寻越中山水》"] +["金带连环束战袍,马头冲雪度临洮。","马戴《出塞词》"] +["日暮诗成天又雪,与梅并作十分春。","卢梅坡《雪梅·其二》"] +["寒月悲笳,万里西风瀚海沙","纳兰性德《采桑子·塞上咏雪花》"] +["路出寒云外,人归暮雪时。","卢纶《李端公 / 送李端》"] +["山远近,路横斜,青旗沽酒有人家。","辛弃疾《鹧鸪天·陌上柔桑破嫩芽》"] +["活水源流随处满,东风花柳逐时新。","于谦《观书》"] +["草满池塘水满陂,山衔落日浸寒漪。","雷震《村晚》"] +["莫莫高山。深谷逶迤。","佚名《紫芝歌》"] +["云中乱拍禄山舞,风过重峦下笑声。","杜牧《过华清宫绝句三首》"] +["远去不逢青海马,力穷难拔蜀山蛇。","李商隐《咏史二首·其二》"] +["隋堤上、曾见几番,拂水飘绵送行色。","周邦彦《兰陵王·柳》"] +["移舟水溅差差绿,倚槛风摆柄柄香。","郑谷《莲叶》"] +["北极朝廷终不改,西山寇盗莫相侵。","杜甫《登楼》"] +["晨昏滚滚水东流,今古悠悠日西坠。","钱福《明日歌》"] +["飞来山上千寻塔,闻说鸡鸣见日升。","王安石《登飞来峰》"] +["白露横江,水光接天。","苏轼《前赤壁赋》"] +["二分尘土,一分流水。","苏轼《水龙吟·次韵章质夫杨花词》"] +["躞蹀御沟上,沟水东西流。","卓文君《白头吟》"] +["正入万山圈子里,一山放过一山拦。","杨万里《过松源晨炊漆公店》"] +["玉在山而草润,渊生珠而崖不枯。","荀子《劝学》"] +["孤之有孔明,犹鱼之有水也。","陈寿《隆中对》"] +["万岁山前珠翠绕,蓬壶殿里笙歌作。","岳飞《满江红·登黄鹤楼有感》"] +["枕障薰炉隔绣帷,二年终日苦相思,杏花明月始应知。","张曙《浣溪沙·枕障薰炉隔绣帷》"] +["暗相思,无处说,惆怅夜来烟月。","韦庄《应天长·别来半岁音书绝》"] +["妖娆艳态,妒风笑月,长殢东君。","李清照《庆清朝·禁幄低张》"] +["绣帐已阑离别梦,玉炉空袅寂寥香。","冯延巳《浣溪沙·春到青门柳色黄》"] +["花满河阳,为君羞褪晨妆茜。","吴文英《烛影摇红·赋德清县圃古红梅》"] +["荆州麦熟茧成蛾,缲丝忆君头绪多。","李白《荆州歌》"] +["最关情,折尽梅花,难寄相思。","周密《高阳台·送陈君衡被召》"] +["美人慵翦上元灯,弹泪倚瑶瑟。","朱敦儒《好事近·春雨细如尘》"] +["鸳鸯俱是白头时,江南渭北三千里。","贺铸《惜余春·急雨收春》"] +["两岸人家微雨后,收红豆,树底纤纤抬素手。","欧阳炯《南乡子·路入南中》"] +["还怕两人俱薄命,再缘悭、剩月零风里。","纳兰性德《金缕曲·亡妇忌日有感》"] +["贱妾茕茕守空房,忧来思君不敢忘,不觉泪下沾衣裳。","曹丕《燕歌行》"] +["天涯倦客,山中归路,望断故园心眼。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["一江烟水照晴岚,两岸人家接画檐,芰荷丛一段秋光淡。","张养浩《水仙子·咏江南》"] +["假令风歇时下来,犹能簸却沧溟水。","李白《上李邕》"] +["肥水东流无尽期。当初不合种相思。","姜夔《鹧鸪天·元夕有所梦》"] +["恨入空帷鸾影独,泪凝双脸渚莲光,薄情年少悔思量。","顾敻《浣溪沙·荷芰风轻帘幕香》"] +["永夜角声悲自语,客心愁破正思家。","汪元量《望江南·幽州九日》"] +["江南几日又天涯,谁与寄相思?","董士锡《木兰花慢·武林归舟中作》"] +["忘却成都来十载,因君未免思量。","苏轼《临江仙·送王缄》"] +["那堪更被明月,隔墙送过秋千影。","张先《青门引·春思》"] +["白下有山皆绕郭,清明无客不思家。","高启《清明呈馆中诸公》"] +["寒灯思旧事,断雁警愁眠。","杜牧《旅宿》"] +["不忍登高临远,望故乡渺邈,归思难收。","柳永《八声甘州·对潇潇暮雨洒江天》"] +["竹坞无尘水槛清,相思迢递隔重城。","李商隐《宿骆氏亭寄怀崔雍崔衮》"] +["四面歌残终破楚,八年风味徒思浙。","秋瑾《满江红·小住京华》"] +["一日不思量,也攒眉千度。","柳永《昼夜乐·洞房记得初相遇》"] +["岭外音书断,经冬复历春。","宋之问《渡汉江》"] +["此心终合雪,去已莫思量。","虚中《送迁客》"] +["香灭绣帷人寂寂,倚槛无言愁思远。","顾敻《玉楼春·柳映玉楼春日晚》"] +["万里乡为梦,三边月作愁。","岑参《送人赴安西》"] +["云物不殊乡国异,教儿且覆掌中杯。","杜甫《小至》"] +["雁尽书难寄,愁多梦不成。","沈如筠《闺怨二首·其一》"] +["年年跃马长安市。客舍似家家似寄。","刘克庄《玉楼春·戏林推》"] +["寒笛对京口,故人在襄阳。","刘昚虚《暮秋扬子江寄孟浩然》"] +["念故人,千里至此共明月。","寇准《踏莎行·寒草烟光阔》"] +["山阴道士如相见,应写黄庭换白鹅。","李白《送贺宾客归越》"] +["玉楼深锁薄情种。清夜悠悠谁共。","秦观《桃源忆故人·玉楼深锁薄情种》"] +["家童扫萝径,昨与故人期。","钱起《谷口书斋寄杨补阙》"] +["河西幕中多故人,故人别来三五春。","岑参《凉州馆中与诸判官夜集》"] +["相逢秋月满,更值夜萤飞。","王绩《秋夜喜遇王处士》"] +["羁旅长堪醉,相留畏晓钟。","戴叔伦《客夜与故人偶集 / 江乡故人偶集客舍》"] +["江上旗亭,送君还是逢君处。","吴大有《点绛唇·送李琴泉》"] +["孤花片叶,断送清秋节。","纳兰性德《清平乐·孤花片叶》"] +["飘零疏酒盏,离别宽衣带。","秦观《千秋岁·水边沙外》"] +["谁向孤舟怜逐客?白云相送大江西。","李攀龙《送明卿之江西》"] +["青枫飒飒雨凄凄,秋色遥看入楚迷。","李攀龙《送明卿之江西》"] +["年年送客横塘路,细雨垂杨系画船。","范成大《横塘·南浦春来绿一川》"] +["升沉应已定,不必问君平。","李白《送友人入蜀》"] +["怕黄昏忽地又黄昏,不销魂怎地不销魂。","王实甫《十二月过尧民歌·别情》"] +["飒飒东风细雨来,芙蓉塘外有轻雷。","李商隐《无题·飒飒东风细雨来》"] +["春阴垂野草青青,时有幽花一树明。","苏舜钦《淮中晚泊犊头》"] +["不见襄阳登览,磨灭游人无数,遗恨黯难收。","陆游《水调歌头·多景楼》"] +["铜铸金镛振纪纲,声传海外播戎羌。","曹雪芹《交趾怀古》"] +["层楼望,春山叠;家何在?","辛弃疾《满江红·点火樱桃》"] +["器乏雕梁器,材非构厦材。","赵彦昭《奉和元日赐群臣柏叶应制》"] +["咸言上国繁华,岂谓帝城羁旅。","子兰《秋日思旧山》"] +["梦绕边城月,心飞故国楼。","李白《太原早秋》"] +["十年驱驰海色寒,孤臣于此望宸銮。","戚继光《望阙台》"] +["玉梅消瘦,恨东皇命薄。","汪元量《传言玉女·钱塘元夕》"] +["汉臣曾此作缧囚,茹血衣毛十九秋。","汪遵《咏北海》"] +["之子归穷泉,重壤永幽隔。","潘安《悼亡诗三首》"] +["行坐深闺里。懒更妆梳,自知新来憔悴。","杜安世《鹤冲天·清明天气》"] +["花片片飞风弄蝶,柳阴阴下水平桥。","张先《浣溪沙·楼倚春江百尺高》"] +["好梦欲成还又觉,绿窗但觉莺啼晓。","施耐庵《蝶恋花·一别家山音信杳》"] +["紫塞门孤,金河月冷,恨谁诉?","朱彝尊《长亭怨慢·雁》"] +["欲笺心事,独语斜阑。","唐婉《钗头凤·世情薄》"] +["独倚阑干凝望远。一川烟草平如剪。","谢逸《蝶恋花·豆蔻梢头春色浅》"] +["此意有谁知,恨与孤鸿远。","杨无咎《生查子·秋来愁更深》"] +["独睡起来情悄悄,寄愁何处好。","纳兰性德《谒金门·风丝袅》"] +["故园今夕是元宵,独向蛮村坐寂寥。","王守仁《元夕二首》"] +["生平未报国,留作忠魂补。","杨继盛《就义诗》"] +["泽国江山入战图,生民何计乐樵苏。","曹松《己亥岁二首·僖宗广明元年》"] +["记得旧时,探梅时节。老来旧事无人说。","吕本中《踏莎行·雪似梅花》"] +["那里有闹红尘香车宝马?祗不过送黄昏古木寒鸦。","王磐《古蟾宫·元宵》"] +["徙倚霜风里,落日伴人愁。","袁去华《水调歌头·定王台》"] +["歌声未尽处,先泪零。","周邦彦《绮寮怨·上马人扶残醉》"] +["零落江南不自由。两绸缪。料得吟鸾夜夜愁。","姜夔《忆王孙·番阳彭氏小楼作》"] +["白兔捣药秋复春,嫦娥孤栖与谁邻?","李白《把酒问月·故人贾淳令予问之》"] +["新园锁却愁阴,露黄漫委,寒香半亩。","吴文英《瑞龙吟·送梅津》"] +["恨旧愁新。有泪无言对晚春。","淮上女《减字木兰花·淮山隐隐》"] +["脱衣换得商山酒,笑把离骚独自倾。","王禹偁《清明日独酌》"] +["月既不解饮,影徒随我身。","李白《月下独酌四首·其一》"] +["夜雨滴空阶,孤馆梦回,情绪萧索。","柳永《尾犯·夜雨滴空阶》"] +["渐老多忧百事忙,天寒日短更心伤。","李觏《秋晚悲怀》"] +["多少新亭挥泪客,谁梦中原块土。","刘克庄《贺新郎·送陈真州子华》"] +["忍泪不能歌,试托哀弦语。","晏几道《生查子·坠雨已辞云》"] +["肌玉暗消衣带缓,泪珠斜透花钿侧。","文天祥《满江红·和王夫人满江红韵以庶几後山妾薄命之意》"] +["但凭阑无语,烟花三月春愁。","郑觉齐《扬州慢·琼花》"] +["偷弹清泪寄烟波,见江头故人,为言憔悴如许。","袁去华《剑器近·夜来雨》"] +["不系雕鞍门前柳,玉容寂寞见花羞,冷风儿吹雨黄昏后。","乔吉《春闺怨·不系雕鞍门前柳》"] +["争教清泪不成冰?好处便轻别。","纳兰性德《好事近·帘外五更风》"] +["送客自伤身易老,不知何处待先生。","刘沧《赠道者》"] +["莫道伤高恨远,付与临风笛。","晏几道《六么令·雪残风信》"] +["胡人吹笛戍楼间,楼上萧条海月闲。","高适《和王七玉门关听吹笛 / 塞上闻笛》"] +["泪滴千千万万行,更使人、愁肠断。","乐婉《卜算子·答施》"] +["柳叶随歌皱,梨花与泪倾。","黄庭坚《南歌子·槐绿低窗暗》"] +["义胆包天,忠肝盖地,四海无人识。","施耐庵《念奴娇·天南地北》"] +["江南江北愁思,分付酒螺红。","方岳《水调歌头·平山堂用东坡韵》"] +["午醉未醒红日晚,黄昏帘幕无人卷。","苏轼《蝶恋花·蝶懒莺慵春过半》"] +["灞桥杨柳年年恨,鸳浦芙蓉叶叶愁。","苏庠《鹧鸪天·枫落河梁野水秋》"] +["霜殒芦花泪湿衣,白头无复倚柴扉。","与恭《思母》"] +["独倚画栏如有意,清砧怨笛送黄昏。","曹雪芹《咏白海棠》"] +["四百年来成一梦,堪愁。","王安石《南乡子·自古帝王州》"] +["去意徊徨,别语愁难听。","周邦彦《蝶恋花·早行》"] +["好去不须频下泪,老僧相伴有烟霞。","金地藏《送童子下山》"] +["无一语,对芳尊。安排肠断到黄昏。","秦观《鹧鸪天·枝上流莺和泪闻》"] +["无言暗将红泪弹。阑珊,香销轻梦还。","纳兰性德《河传·春浅》"] +["伤情燕足留红线,恼人鸾影闲团扇。","张可久《塞鸿秋·春情》"] +["无人知此意,歌罢满帘风。","陈与义《临江仙·高咏楚词酬午日》"] +["枝上流莺和泪闻,新啼痕间旧啼痕。","秦观《鹧鸪天·枝上流莺和泪闻》"] +["伏雨朝寒愁不胜,那能还傍杏花行。","纳兰性德《浣溪沙·伏雨朝寒愁不胜》"] +["羞日遮罗袖,愁春懒起妆。","鱼玄机《赠邻女 / 寄李亿员外》"] +["一夜东风,枕边吹散愁多少。","曾允元《点绛唇·一夜东风》"] +["暝色入高楼,有人楼上愁。","李白《菩萨蛮·平林漠漠烟如织》"] +["有情风、万里卷潮来,无情送潮归。","苏轼《八声甘州·寄参寥子》"] +["今为羌笛出塞声,使我三军泪如雨。","李颀《古意》"] +["伤情处,高城望断,灯火已黄昏。","秦观《满庭芳·山抹微云》"] +["半烟半雨溪桥畔,渔翁醉着无人唤。","黄庭坚《菩萨蛮·半烟半雨溪桥畔》"] +["蓬门未识绮罗香,拟托良媒益自伤。","秦韬玉《贫女》"] +["困倚危楼。过尽飞鸿字字愁。","秦观《减字木兰花·天涯旧恨》"] +["留连光景惜朱颜,黄昏独倚阑。","李煜《阮郎归·呈郑王十二弟》"] +["是他春带愁来,春归何处。","辛弃疾《祝英台近·晚春》"] +["贾生年少虚垂泪,王粲春来更远游。","李商隐《安定城楼》"] +["林卧愁春尽,开轩览物华。","孟浩然《清明日宴梅道士房 / 宴梅道士山房》"] +["晓风不散愁千点,宿雨还添泪一痕。","曹雪芹《咏白海棠》"] +["帘控钩,掩上珠楼,风雨替花愁。","乔吉《春闺怨·不系雕鞍门前柳》"] +["衣懒换,酒难赊。可怜此夕看梅花。","吴文英《思佳客·癸卯除夜》"] +["日落君山云气,春到沅湘草木,远思渺难收。","张孝祥《水调歌头·过岳阳楼作》"] +["伤心秦汉经行处,宫阙万间都做了土。","张养浩《山坡羊·潼关怀古》"] +["只向从前悔薄情,凭仗丹青重省识,盈盈,一片伤心画不成。","纳兰性德《南乡子·为亡妇题照》"] +["楼上黄昏欲望休,玉梯横绝月如钩。","李商隐《代赠二首·其一》"] +["望极春愁,黯黯生天际。","柳永《蝶恋花·伫倚危楼风细细》"] +["天接云涛连晓雾,星河欲转千帆舞。","李清照《渔家傲·天接云涛连晓雾》"] +["靡不有初,鲜克有终。","佚名《荡》"] +["手如柔荑,肤如凝脂","佚名《硕人》"] +["问姓惊初见,称名忆旧容。","李益《喜见外弟又言别》"] +["相顾无相识,长歌怀采薇。","王绩《野望》"] +["豆蔻连梢煎熟水,莫分茶。","李清照《摊破浣溪沙·病起萧萧两鬓华》"] +["世间珍果更无加,玉雪肌肤罩绛纱。","丘浚《咏荔枝》"] +["劝君终日酩酊醉,酒不到刘伶坟上土。","李贺《将进酒》"] +["何时一樽酒,重与细论文。","杜甫《春日忆李白》"] +["莺莺燕燕分飞后,粉淡梨花瘦。","元好问《虞美人·槐阴别院宜清昼》"] +["漠漠梨花烂漫,纷纷柳絮飞残。","陈允平《红林擒近·寿词·满路花》"] +["谁知得酒尚能狂,脱帽向人时大叫。","陆游《三月十七日夜醉中作》"] +["黄莺不语东风起,深闭朱门伴舞腰。","温庭筠《杨柳八首·其三》"] +["闲碾凤团消短梦,静看燕子垒新巢。","周邦彦《浣溪沙·水涨鱼天拍柳桥》"] +["燕子衔将春色去,纱窗几阵黄梅雨。","司马槱《黄金缕·妾本钱塘江上住》"] +["行到小溪深处,有黄鹂千百。","秦观《好事近·梦中作》"] +["风吹芳兰折,日没鸟雀喧。","李白《送裴十八图南归嵩山二首》"] +["半床落月蛩声病,万里寒云雁阵迟。","曹雪芹《残菊》"] +["从此信音稀,岭南无雁飞。","李师中《菩萨蛮·子规啼破城楼月》"] +["青衫匹马万人呼,幕府当年急急符。","辛弃疾《送湖南部曲》"] +["惟有两行低雁,知人倚、画楼月。","范成大《霜天晓角·梅》"] +["沽酒清阴时系马,招凉短槛几留题。","许德溥《西桥柳色》"] +["叠嶂西驰,万马回旋,众山欲东。","辛弃疾《沁园春·灵山斋庵赋时筑偃湖未成》"] +["虢国夫人承主恩,平明骑马入宫门。","张祜《集灵台·其二》"] +["古来存老马,不必取长途。","杜甫《江汉》"] +["渡江天马南来,几人真是经纶手。","辛弃疾《水龙吟·甲辰岁寿韩南涧尚书》"] +["归时休放烛光红,待踏马蹄清夜月。","李煜《木兰花·晓妆初了明肌雪》"] +["单于北望拂云堆,杀马登坛祭几回。","王之涣《凉州词二首》"] +["帐底吹笙香吐麝,更无一点尘随马。","苏轼《蝶恋花·密州上元》"] +["花隐掖垣暮,啾啾栖鸟过。","杜甫《春宿左省》"] +["飞鸟没何处,青山空向人。","刘长卿《饯别王十一南游》"] +["越鸟巢干后,归飞体更轻。","李商隐《晚晴》"] +["牧人驱犊返,猎马带禽归。","王绩《野望》"] +["此马非凡马,房星本是星。","李贺《马诗二十三首·其四》"] +["金吾不禁夜,玉漏莫相催。","苏味道《正月十五夜》"] +["锦里开芳宴,兰缸艳早年。","卢照邻《十五夜观灯》"] +["记晓叶题霜,秋灯吟雨,曾系长桥过艇。","吴文英《十二郎·垂虹桥》"] +["酹酒苍茫,倚歌平远,亭上玉虹腰冷。","吴文英《十二郎·垂虹桥》"] +["第四桥边,拟共天随住。","姜夔《点绛唇·丁未冬过吴松作》"] +["公子南桥应尽兴,将军西第几留宾。","杜审言《春日京中有怀》"] +["万条千缕绿相迎。舞烟眠雨过清明。","晏几道《浣溪沙·二月和风到碧城》"] +["五月五日午,赠我一枝艾。","文天祥《端午即事》"] +["清明过了,不堪回首,云锁朱楼。","朱淑真《眼儿媚·风日迟迟弄轻柔》"] +["箫鼓喧,人影参差,满路飘香麝。","周邦彦《解语花·上元》"] +["清愁诗酒少,寒食雨风多。","张镃《寒食》"] +["清晓妆成寒食天,柳球斜袅间花钿,卷帘直出画堂前。","韦庄《浣溪沙·清晓妆成寒食天》"] +["怀家寒食夜,中酒落花天。","赵长卿《临江仙·暮春》"] +["两情缠绵忽如故。复畏秋风生晓路。","王建《七夕曲》"] +["恐是仙家好别离,故教迢递作佳期。","李商隐《辛未七夕》"] +["穿针人在合欢楼,正月露、玉盘高泻。","严蕊《鹊桥仙·碧梧初出》"] +["鹤发垂肩尺许长,离家三十五端阳。","殷尧藩《同州端午》"] +["万里婵娟,几许雾屏云幔。","吴文英《玉漏迟·瓜泾度中秋夕赋》"] +["柳下系舟犹未稳,能几日、又中秋。","刘过《唐多令·芦叶满汀洲》"] +["何期今日酒,忽对故园花。","文森《九日》"] +["淮南秋雨夜,高斋闻雁来。","韦应物《闻雁》"] +["因思杜陵梦,凫雁满回塘。","温庭筠《商山早行》"] +["野鸦无意绪,鸣噪自纷纷。","杜甫《孤雁 / 后飞雁》"] +["相望试登高,心随雁飞灭。","孟浩然《秋登兰山寄张五》"] +["扁舟去作江南客,旅雁孤云。","朱敦儒《采桑子·彭浪矶》"] +["花开红树乱莺啼,草长平湖白鹭飞。","徐元杰《湖上》"] +["冉冉年时暮,迢迢天路征。","陆机《梁甫吟》"] +["长恨去年今夜雨,洒离亭。","晏几道《愁倚阑令·花阴月》"] +["慷慨十年长剑在,登楼一笑暮山横。","崔允《花马池咏》"] +["芳草年年惹恨幽。想前事悠悠。","石延年《燕归梁·春愁》"] +["十年重见,依旧秀色照清眸。","王以宁《水调歌头·呈汉阳使君》"] +["寂寞柴门村落里,也教插柳记年华。","赵元镇《寒食书事》"] +["杏树坛边渔父,桃花源里人家。","王维《田园乐七首·其三》"] +["观书老眼明如镜,论事惊人胆满躯。","辛弃疾《送湖南部曲》"] +["将军角弓不得控,都护铁衣冷难着。","岑参《白雪歌送武判官归京》"] +["天涯静处无征战,兵气销为日月光。","常建《塞下曲四首·其一》"] +["集中什九从军乐,亘古男儿一放翁。","梁启超《读陆放翁集》"] +["生事且弥漫,愿为持竿叟。","綦毋潜《春泛若耶溪》"] +["能令暂开霁,过是吾无求。","韩愈《洞庭湖阻风赠张十一署·时自阳山徙掾江陵》"] +["我欲乘槎,直穷银汉,问津深入。","许有壬《水龙吟·过黄河》"] +["军中宜剑舞,塞上重笳音。","张说《幽州夜饮》"] +["汉将归来虏塞空,旌旗初下玉关东。","戎昱《塞下曲》"] +["古宫闲地少,水港小桥多。","杜荀鹤《送人游吴》"] +["曲终过尽松陵路,回首烟波十四桥。","姜夔《过垂虹》"] +["单车欲问边,属国过居延。","王维《使至塞上》"] +["卷旗夜劫单于帐,乱斫胡儿缺宝刀。","马戴《出塞词》"] +["今夜不知何处宿,平沙万里绝人烟。","岑参《碛中作》"] +["田家几日闲,耕种从此起。","韦应物《观田家》"] +["杏花村馆酒旗风。水溶溶。扬残红。","谢逸《江神子·杏花村馆酒旗风》"] +["九华山路云遮寺,清弋江村柳拂桥。","杜牧《宣州送裴坦判官往舒州时牧欲赴官归京》"] +["村喜禾花实,峰看岭岫重。","赵汝愚《雨后送李将军还祠偕同寅饮一杯亭》"] +["锦瑟华年谁与度?","贺铸《青玉案·凌波不过横塘路》"] +["怅寥廓,问苍茫大地,谁主沉浮?","毛泽东《沁园春·长沙》"] +["瘦影自临春水照,卿须怜我我怜卿。","冯小青《怨》"] +["岂曰无衣?与子同袍。","佚名《秦风·无衣》"] +["越女新妆出镜心,自知明艳更沉吟。","张籍《酬朱庆馀》"] +["死生契阔,与子成说。","佚名《击鼓》"] +["我心匪石,不可转也。","佚名《柏舟》"] +["应念岭表经年,孤光自照,肝胆皆冰雪。","张孝祥《念奴娇·过洞庭》"] +["静女其姝,俟我于城隅。","佚名《静女》"] +["氓之蚩蚩,抱布贸丝。","佚名《氓》"] +["树深时见鹿,溪午不闻钟。","李白《访戴天山道士不遇》"] +["寂寂寥寥扬子居,年年岁岁一床书。","卢照邻《长安古意》"] +["对酒当歌,人生几何!","曹操《短歌行》"] +["七月流火,九月授衣。","佚名《七月》"] +["讯予不顾,颠倒思予。","佚名《墓门》"] +["宜言饮酒,与子偕老。","佚名《女曰鸡鸣》"] +["既见君子,云胡不瘳。","佚名《风雨》"] +["蒹葭采采,白露未已。","佚名《蒹葭》"] +["百川东到海,何时复西归?","佚名《长歌行》"] +["独立小桥风满袖,平林新月人归后。","冯延巳《鹊踏枝·谁道闲情抛掷久》"] +["天南地北双飞客,老翅几回寒暑。","元好问《摸鱼儿·雁丘词 / 迈陂塘》"] +["灯半昏时,月半明时。","徐再思《折桂令·春情》"] +["花谢花飞花满天,红消香断有谁怜?","曹雪芹《葬花吟》"] +["君不见李北海,英风豪气今何在!","李白《答王十二寒夜独酌有怀》"] +["今古恨,几千般,只应离合是悲欢?","辛弃疾《鹧鸪天·送人》"] +["世事一场大梦,人生几度秋凉?","苏轼《西江月·世事一场大梦》"] +["才子词人,自是白衣卿相。","柳永《鹤冲天·黄金榜上》"] +["莫等闲,白了少年头,空悲切!","岳飞《满江红·写怀》"] +["彼黍离离,彼稷之穗。","佚名《黍离》"] +["中心藏之,何日忘之!","佚名《隰桑》"] +["青山如黛远村东,嫩绿长溪柳絮风。","高珩《春日杂咏》"] +["村北村南布谷忙,村前村后稻花香。","方向《咏儋耳二首》"] +["疏篱曲径田家小。云树开清晓。","周邦彦《虞美人·疏篱曲径田家小》"] +["平沙芳草渡头村。绿遍去年痕。","洪咨夔《眼儿媚·平沙芳草渡头村》"] +["水隔淡烟修竹寺,路经疏雨落花村。","杨徽之《寒食寄郑起侍郎》"] +["二月卖新丝,五月粜新谷。","聂夷中《咏田家 / 伤田家》"] +["蚕无夏织桑充寨,田废春耕犊劳军。","杜荀鹤《题所居村舍》"] +["西风八九月,积地秋云黄。","黄燮清《秋日田家杂咏》"] +["湖上春既早,田家日不闲。","丘为《题农父庐舍》"] +["燕歌未断塞鸿飞,牧马群嘶边草绿。","李益《塞下曲·其一》"] +["横戈从百战,直为衔恩甚。","李白《塞下曲六首》"] +["平岸小桥千嶂抱。柔蓝一水萦花草。","王安石《渔家傲·平岸小桥千嶂抱》"] +["黄云连白草,万里有无间。","王镃《塞上曲》"] +["梦魂惯得无拘检,又踏杨花过谢桥。","晏几道《鹧鸪天·小令尊前见玉箫》"] +["幼敏悟过人,读书辄成诵。","欧阳修《画地学书》"] +["愿月常圆,休要暂时缺。","赵佶《醉落魄·预赏景龙门追悼明节皇后》"] +["东风吹落战尘沙,梦想西湖处士家;","刘因《观梅有感》"] +["军合力不齐,踌躇而雁行。","曹操《蒿里行》"] +["寄书长不达,况乃未休兵。","杜甫《月夜忆舍弟》"] +["将军金甲夜不脱,半夜军行戈相拨,风头如刀面如割。","岑参《走马川行奉送出师西征 / 走马川行奉送封大夫出师西征》"] +["二客东南名胜,万卷诗书事业,尝试与君谋。","辛弃疾《水调歌头·舟次扬州和人韵》"] +["山下孤烟远村,天边独树高原。","王维《田园乐七首·其五》"] +["欲寄书如天远,难销夜似年长。","贺铸《西江月·携手看花深径》"] +["当年志气俱消尽,白发新添四五茎。","薛逢《长安夜雨》"] +["壮年何事憔悴,华发改朱颜。","苏舜钦《水调歌头·沧浪亭》"] +["记得年时,相见画屏中。","谢逸《江神子·杏花村馆酒旗风》"] +["少小虽非投笔吏,论功还欲请长缨。","祖咏《望蓟门》"] +["紫泉宫殿锁烟霞,欲取芜城作帝家。","李商隐《隋宫》"] +["万里人南去,三春雁北飞。","韦承庆《南中咏雁诗》"] +["塞雁高飞人未还,一帘风月闲。","李煜《长相思·一重山》"] +["燕雁无心,太湖西畔随云去。","姜夔《点绛唇·丁未冬过吴松作》"] +["三载重阳菊,开时不在家。","文森《九日》"] +["佳节又重阳,玉枕纱厨,半夜凉初透。","李清照《醉花阴·薄雾浓云愁永昼》"] +["素娥无赖,西去曾不为人留。","苏辙《水调歌头·徐州中秋》"] +["阴晴圆缺都休说,且喜人间好时节。","徐有贞《中秋月·中秋月》"] +["想子胥今夜见嫦娥,沉冤雪。","史达祖《满江红·中秋夜潮》"] +["一道鹊桥横渺渺,千声玉佩过玲玲。","徐凝《七夕》"] +["拆桐花烂熳,乍疏雨、洗清明。","柳永《木兰花慢·拆桐花烂熳》"] +["东飞乌鹊西飞燕。盈盈一水经年见。","陈师道《菩萨蛮·七夕》"] +["天上若无修月户,桂枝撑损向西轮。","米芾《中秋登楼望月》"] +["听元宵,往岁喧哗,歌也千家,舞也千家。","王磐《古蟾宫·元宵》"] +["夜深斜搭秋千索,楼阁朦胧烟雨中。","韩偓《夜深 / 寒食夜》"] +["把酒看花想诸弟,杜陵寒食草青青。","韦应物《寒食寄京师诸弟》"] +["又酒趁哀弦,灯照离席。梨花榆火催寒食。","周邦彦《兰陵王·柳》"] +["清明时节出郊原,寂寂山城柳映门。","杨徽之《寒食寄郑起侍郎》"] +["残杏枝头花几许。啼红正恨清明雨。","赵令畤《蝶恋花·欲减罗衣寒未去》"] +["乌啼鹊噪昏乔木,清明寒食谁家哭。","白居易《寒食野望吟》"] +["况是清明好天气,不妨游衍莫忘归。","程颢《郊行即事》"] +["不似当时,小桥冲雨,幽恨两人知。","周邦彦《少年游·朝云漠漠散轻丝》"] +["寺忆曾游处,桥怜再渡时。","杜甫《后游》"] +["莫道蓝桥路远,行云中隔幽坊。","吴文英《风入松·为友人放琴客赋》"] +["绿阴青子老溪桥。羞见东邻娇小。","吴文英《西江月·赋瑶圃青梅枝上晚花》"] +["玉漏银壶且莫催,铁关金锁彻明开。","崔液《上元夜六首·其一》"] +["元宵佳节,融和天气,次第岂无风雨。","李清照《永遇乐·落日熔金》"] +["是无猫邪,是不会蓄猫也。","乐钧《世无良猫》"] +["天寒水鸟自相依,十百为群戏落晖。","秦观《还自广陵》"] +["梦中未比丹青见,暗里忽惊山鸟啼。","姜夔《鹧鸪天·元夕有所梦》"] +["一去无消息,那能惜马蹄。","薛道衡《昔昔盐》"] +["青山欲共高人语。联翩万马来无数。","辛弃疾《菩萨蛮·金陵赏心亭为叶丞相赋》"] +["甚时跃马归来,认得迎门轻笑。","时彦《青门饮·寄宠人》"] +["塞上秋风鼓角,城头落日旌旗。少年鞍马适相宜。","元好问《江月晃重山·初到嵩山时作》"] +["马嘶经战地,雕认打围山。","王镃《塞上曲》"] +["六六雁行连八九,只待金鸡消息。","施耐庵《念奴娇·天南地北》"] +["花过雨。又是一番红素。燕子归来愁不语。","李好古《谒金门·花过雨》"] +["天际疏星趁马,帘昼隙、冰弦三叠。","吴文英《暗香·送魏句滨宰吴县解组分韵得阖字》"] +["神椎凿石塞神潭,白马参覃赤尘起。","温庭筠《拂舞词 / 公无渡河》"] +["门前行乐客,白马嘶春色。","牛峤《菩萨蛮·风帘燕舞莺啼柳》"] +["马毛缩如蝟,角弓不可张。","鲍照《代出自蓟北门行》"] +["便向夕阳影里,倚马挥毫。","纳兰性德《风流子·秋郊即事》"] +["胡风吹代马,北拥鲁阳关。","李白《豫章行》"] +["五马如飞龙,青丝结金络。","李白《陌上桑》"] +["窗白一声鸡,枕函闻马嘶。","顾贞观《菩萨蛮·山城夜半催金柝》"] +["照野旌旗,朝天车马,平沙万里天低。","周密《高阳台·送陈君衡被召》"] +["下马登邺城,城空复何见。","岑参《登古邺城》"] +["生怕见花开花落,朝来塞雁先还。","辛弃疾《汉宫春·立春日》"] +["君思我、回首处,正江涵秋影雁初飞。","辛弃疾《木兰花慢·席上送张仲固帅兴元》"] +["日夕北风紧,寒林噤暮鸦。","大须《暮雪》"] +["只言啼鸟堪求侣,无那春风欲送行。","高适《夜别韦司士》"] +["暮雨忽来鸿雁杳,莽关山、一派秋声里。","龚自珍《金缕曲·癸酉秋出都述怀有赋》"] +["铜华沧海,愁霾重嶂,燕北雁南天外。","吴文英《永遇乐·乙巳中秋风雨》"] +["人迷洞庭水,雁度潇湘烟。","李白《郢门秋怀》"] +["凤凰城阙知何处,寥落星河一雁飞。","贺铸《思越人·紫府东风放夜时》"] +["古岩泉滴滴,幽谷鸟关关。","令狐楚《赋山 / 一七令·山》"] +["渡口双双飞白鸟,烟袅,芦花深处隐渔歌。","阎选《定风波·江水沉沉帆影过》"] +["燕子又将春色去。纱窗一阵黄昏雨。","司马槱《黄金缕·家在钱塘江上住》"] +["雁湿行无次,花沾色更鲜。","李世民《咏雨》"] +["崖崩路绝猿鸟去,惟有乔木搀天长。","苏轼《李思训画长江绝岛图》"] +["禁里疏钟官舍晚,省中啼鸟吏人稀。","王维《酬郭给事 / 赠郭给事》"] +["穗帷飘井干,樽酒若平生。","谢朓《同谢谘议咏铜雀台》"] +["木兰舟上珠帘卷,歌声远,椰子酒倾鹦鹉盏。","李珣《南乡子·山果熟》"] +["东风有恨致玄都,吹破枝头玉,夜月梨花也相妒。","周文质《小桃红·咏桃》"] +["酒肆人间世,琴台日暮云。","杜甫《琴台》"] +["杖剑对尊酒,耻为游子颜。","陆龟蒙《别离》"] +["送行无酒亦无钱,劝尔一杯菩萨泉。","苏轼《武昌酌菩萨泉送王子立》"] +["何时更杯酒,再得论心胸。","李白《魏郡别苏明府因北游》"] +["鹔鹴换美酒,舞衣罢雕龙。","李白《怨歌行》"] +["报答春光知有处,应须美酒送生涯。","杜甫《江畔独步寻花七绝句》"] +["欢言得所憩,美酒聊共挥。","李白《下终南山过斛斯山人宿置酒》"] +["主人有酒欢今夕,请奏鸣琴广陵客。","李颀《琴歌》"] +["酒后竞风采,三杯弄宝刀。","李白《白马篇》"] +["酒阑更喜团茶苦,梦断偏宜瑞脑香。","李清照《鹧鸪天·寒日萧萧上琐窗》"] +["云外好呼南去雁,系书先为报江乡。","齐之鸾《九日登清水营城》"] +["井放辘轳闲浸酒,笼开鹦鹉报煎茶。","张蠙《夏日题老将林亭》"] +["商人重利轻别离,前月浮梁买茶去。","白居易《琵琶行 / 琵琶引》"] +["把酒祝东风,且莫恁、匆匆去。","王安石《伤春怨·雨打江南树》"] +["晴空一鹤排云上,便引诗情到碧霄。","刘禹锡《秋词》"] +["大凡物不得其平则鸣","韩愈《送孟东野序》"] +["此曲只应天上有,人间能得几回闻。","杜甫《赠花卿》"] +["清水出芙蓉,天然去雕饰。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["朱门酒肉臭,路有冻死骨。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["它山之石,可以攻玉。","佚名《鹤鸣》"] +["刑天舞干戚,猛志固常在。","陶渊明《读山海经·其十》"] +["出师一表真名世,千载谁堪伯仲间!","陆游《书愤五首·其一》"] +["山高月小,水落石出","苏轼《后赤壁赋》"] +["祸患常积于忽微,而智勇多困于所溺","欧阳修《伶官传序》"] +["剪不断,理还乱,是离愁。别是一般滋味在心头。","李煜《相见欢·无言独上西楼》"] +["青苔满地初晴后,绿树无人昼梦余。","刘攽《新晴·青苔满地初晴后》"] +["残霞夕照西湖好,花坞苹汀,十顷波平,野岸无人舟自横。","欧阳修《采桑子·残霞夕照西湖好》"] +["无情有恨何人觉?月晓风清欲堕时。","陆龟蒙《白莲》"] +["曾把梨花,寂寞泪阑干。","朱淑真《江城子·赏春》"] +["帝乡三千里,杳在碧云间。","李白《登敬亭北二小山余时送客逢崔侍御并登此地》"] +["寒山吹笛唤春归,迁客相看泪满衣。","李益《春夜闻笛》"] +["愁颜与衰鬓,明日又逢春。","戴叔伦《除夜宿石头驿》"] +["枕上潜垂泪,花间暗断肠。","鱼玄机《赠邻女 / 寄李亿员外》"] +["萧条庭院,又斜风细雨,重门须闭。","李清照《念奴娇·春情》"] +["新欢不抵旧愁多,倒添了、新愁归去。","范成大《鹊桥仙·七夕》"] +["吾闻马周昔作新丰客,天荒地老无人识。","李贺《致酒行》"] +["桂叶双眉久不描,残妆和泪污红绡。","江采萍《谢赐珍珠》"] +["伤心枕上三更雨,点滴霖霪。","李清照《添字丑奴儿·窗前谁种芭蕉树》"] +["庭户无人秋月明,夜霜欲落气先清。","张耒《夜坐·庭户无人秋月明》"] +["身外闲愁空满,眼中欢事常稀。","晏几道《临江仙·身外闲愁空满》"] +["但使情亲千里近,须信。无情对面是山河。","辛弃疾《定风波·席上送范廓之游建康》"] +["海燕未来人斗草,江梅已过柳生绵。黄昏疏雨湿秋千。","李清照《浣溪沙·淡荡春光寒食天》"] +["泪湿阑干花著露。愁到眉峰碧聚。","毛滂《惜分飞·泪湿阑干花著露》"] +["玉皇开碧落,银界失黄昏。","毛滂《临江仙·都城元夕》"] +["草木虽无情,因依尚可生。","李白《树中草》"] +["深宫桃李无人问,旧爱玉颜今自恨。","元好问《玉楼春·惊沙猎猎风成阵》"] +["去年东武今夕,明月不胜愁。","苏辙《水调歌头·徐州中秋》"] +["燕语如伤旧国春,宫花一落已成尘。","李益《隋宫燕》"] +["醉中浑不记,归路月黄昏。","辛弃疾《临江仙·探梅》"] +["关河无限清愁,不堪临鉴。","岳珂《祝英台近·北固亭》"] +["恼乱横波秋一寸。斜阳只与黄昏近。","赵令畤《蝶恋花·卷絮风头寒欲尽》"] +["黄昏鼓角似边州,三十年前上此楼。","李益《上汝州郡楼》"] +["借钱塘潮汐,为君洗尽,岳将军泪!","陈德武《水龙吟·西湖怀古》"] +["拟把伤离情绪,待晓寒重说。","纳兰性德《好事近·帘外五更风》"] +["只愁飞诏下青冥。不应霜塞晚,横槊看诗成。","周紫芝《临江仙·送光州曾使君》"] +["歌里千重意,才欲歌时泪已流,恨应更、多于泪。","杜安世《卜算子·樽前一曲歌》"] +["想得此时情切,泪沾红袖黦。","韦庄《应天长·别来半岁音书绝》"] +["发短愁催白,颜衰酒借红。","陈师道《除夜对酒赠少章》"] +["深院锁黄昏,阵阵芭蕉雨。","欧阳修《生查子·含羞整翠鬟》"] +["待约个梅魂,黄昏月淡,与伊深怜低语。","柳如是《金明池·咏寒柳》"] +["扰扰马足车尘,被岁月无情,暗消年少。","元好问《玉漏迟·咏杯》"] +["近来何处有吾愁,何处还知吾乐。","辛弃疾《念奴娇·赋雨岩》"] +["阑干风冷雪漫漫,惆怅无人把钓竿。","姜夔《钓雪亭》"] +["自笑好山如好色,只今怀树更怀人。闲愁闲恨一番新。","辛弃疾《浣溪沙·偕叔高子似宿山寺戏作》"] +["月色穿帘风入竹,倚屏双黛愁时。","顾敻《临江仙·月色穿帘风入竹》"] +["秋寂寞。秋风夜雨伤离索。","孙道绚《忆秦娥·季温老友归樵阳人来闲书因以为寄》"] +["雁足无书古塞幽。一程烟草一程愁。","史达祖《鹧鸪天·卫县道中有怀其人》"] +["玉屏风冷愁人。醉烂漫、梅花翠云。","吴文英《柳梢青·与龟翁登研意观雪怀癸卯岁腊朝断桥并马之游》"] +["关河底事空留客?岁月无情不贷人。","于谦《立春日感怀》"] +["雍雍新雁咽寒声,愁恨年年长相似。","李煜《谢新恩·冉冉秋光留不住》"] +["花前失却游春侣,独自寻芳。","冯延巳《采桑子·花前失却游春侣》"] +["牵牛织女遥相望,尔独何辜限河梁。","曹丕《燕歌行二首·其一》"] +["客心已百念,孤游重千里。","何逊《相送》"] +["同为懒慢园林客,共对萧条雨雪天。","白居易《雪夜小饮赠梦得》"] +["却愁拥髻向灯前,说不尽、离人话。","纳兰性德《一络索·过尽遥山如画》"] +["慷慨惟平生,俯仰独悲伤。","陆机《门有车马客行》"] +["宫粉雕痕,仙云堕影,无人野水荒湾。","吴文英《高阳台·落梅》"] +["十年愁眼泪巴巴。今日思家。明日思家。","汪元量《一剪梅·怀旧》"] +["小来思报国,不是爱封侯。","岑参《送人赴安西》"] +["朱门沉沉按歌舞,厩马肥死弓断弦。","陆游《关山月》"] +["杨柳风柔,海棠月淡,独自倚阑时。","萨都剌《小阑干·去年人在凤凰池》"] +["水墨画疏窗,孤影淡潇湘。","纳兰性德《木兰花慢·立秋夜雨送梁汾南行》"] +["残灯孤枕梦,轻浪五更风。","徐昌图《临江仙·饮散离亭西去》"] +["最无端处,总把良宵,只恁孤眠却。","柳永《尾犯·夜雨滴空阶》"] +["独立雕栏,谁怜枉度华年。","王沂孙《高阳台·残萼梅酸》"] +["为问元戎窦车骑,何时返旆勒燕然。","皇甫冉《春思》"] +["离宫绝旷,身体摧藏,志念没沉,不得颉颃。","王昭君《怨词》"] +["睡里消魂无说处。觉来惆怅消魂误。","晏几道《蝶恋花·梦入江南烟水路》"] +["新人虽完好,未若故人姝。","佚名《上山采蘼芜》"] +["暗灯凉簟怨分离,妖姬,不胜悲。","阎选《河传·秋雨》"] +["恨身翻不作车尘,万里得随君。","欧阳炯《巫山一段云·春去秋来也》"] +["薄晚西风吹雨到。明朝又是伤流潦。","王国维《蝶恋花·百尺朱楼临大道》"] +["霸业成空,遗恨无穷。","查德卿《蟾宫曲·怀古》"] +["只有精忠能报国,更无乐土可为家。","董必武《元旦口占用柳亚子怀人韵》"] +["缟素酬家国,戈船决死生!","夏完淳《即事·复楚情何极》"] +["放逐肯消亡国恨?岁时犹动楚人哀!","查慎行《三闾祠》"] +["提笼忘采叶,昨夜梦渔阳。","张仲素《春闺思》"] +["赤壁矶头,一番过、一番怀古。","戴复古《满江红·赤壁怀古》"] +["江山留胜迹,我辈复登临。","孟浩然《与诸子登岘山》"] +["三百年间同晓梦,钟山何处有龙盘。","李商隐《咏史二首·其一》"] +["慨当初,倚飞何重,后来何酷。","文征明《满江红·拂拭残碑》"] +["白头灯影凉宵里,一局残棋见六朝。","钱谦益《金陵后观棋绝句六首·选一》"] +["十日雨丝风片里,浓春艳景似残秋。","王士祯《秦淮杂诗二十首·其一》"] +["朝阳不再盛,白日忽西幽。","阮籍《咏怀八十二首·其三十二》"] +["肃肃花絮晚,菲菲红素轻。","杜甫《春远 / 春运》"] +["侵陵雪色还萱草,漏泄春光有柳条。","杜甫《腊日》"] +["闻道春还未相识,走傍寒梅访消息。","李白《早春寄王汉阳》"] +["诗家清景在新春,绿柳才黄半未匀。","杨巨源《城东早春》"] +["潭州官舍暮楼空,今古无端入望中。","李商隐《潭州》"] +["此身如传舍,何处是吾乡。","苏轼《临江仙·送王缄》"] +["鸿雁不堪愁里听,云山况是客中过。","李颀《送魏万之京》"] +["六翮飘飖私自怜,一离京洛十馀年。","高适《别董大二首》"] +["去年相送,馀杭门外,飞雪似杨花。","苏轼《少年游·润州作》"] +["远树带行客,孤城当落晖。","王维《送綦毋潜落第还乡 / 送别》"] +["别离在今晨,见尔当何秋。","韦应物《送杨氏女》"] +["锁离愁,连绵无际,来时陌上初熏。","韩缜《凤箫吟·锁离愁》"] +["金陵子弟来相送,欲行不行各尽觞。","李白《金陵酒肆留别》"] +["春风知别苦,不遣柳条青。","李白《劳劳亭》"] +["孤云将野鹤,岂向人间住。","刘长卿《送方外上人 / 送上人》"] +["离恨做成春夜雨。添得春江,划地东流去。","杨炎正《蝶恋花·别范南伯》"] +["坐上别愁君未见,归来欲断无肠。","苏轼《临江仙·送王缄》"] +["放眼暮江千顷,中有离愁万斛,无处落征鸿。","杨炎正《水调歌头·把酒对斜日》"] +["春水别来应到海,小松生命合禁霜。","李觏《秋晚悲怀》"] +["嗟万事难忘,惟是轻别。","周邦彦《浪淘沙慢·晓阴重》"] +["是别有人间,那边才见,光影东头。","辛弃疾《木兰花慢·中秋饮酒将旦客谓前人诗词有赋待月无送月者因用天问体赋》"] +["望望不见君,连山起烟雾。","李白《金乡送韦八之西京》"] +["丝槐烟柳长亭路,恨取次、分离去。","惠洪《青玉案·丝槐烟柳长亭路》"] +["蕃汉断消息,死生长别离。","张籍《没蕃故人》"] +["云深山坞,烟冷江皋,人生未易相逢。","吴文英《声声慢·友人以梅兰瑞香水仙供客曰四香分韵得风字》"] +["料因循误了,残毡拥雪,故人心眼。","张炎《解连环·孤雁》"] +["南去北来徒自老,故人稀。","贺铸《钓船归·绿净春深好染衣》"] +["故人不可见,新知万里外。","文天祥《端午即事》"] +["日暮征帆何处泊,天涯一望断人肠。","孟浩然《送杜十四之江南 / 送杜晃进士之东吴》"] +["汉月垂乡泪,胡沙费马蹄。","岑参《碛西头送李判官入京》"] +["过尽征鸿来尽燕,故园消息茫然。","赵长卿《临江仙·暮春》"] +["何事吟余忽惆怅,村桥原树似吾乡。","王禹偁《村行·马穿山径菊初黄》"] +["我行殊未已,何日复归来。","宋之问《题大庾岭北驿》"] +["他乡生白发,旧国见青山。","司空曙《贼平后送人北归》"] +["楚水巴山江雨多,巴人能唱本乡歌。","刘禹锡《竹枝词二首·其二》"] +["九月九日眺山川,归心归望积风烟。","卢照邻《九月九日玄武山旅眺》"] +["杨柳乍如丝,故园春尽时。","纳兰性德《菩萨蛮·问君何事轻离别》"] +["傥寄相思字,愁人定解颜。","钱起《送夏侯审校书东归》"] +["抛家傍路,思量却是,无情有思。","苏轼《水龙吟·次韵章质夫杨花词》"] +["归雁横秋,倦客思家。","张可久《折桂令·九日》"] +["春思远,谁叹赏、国香风味。","周密《绣鸾凤花犯·赋水仙》"] +["与君别。相思一夜梅花发。","房舜卿《忆秦娥·与君别》"] +["西陆蝉声唱,南冠客思深。","骆宾王《咏蝉 / 在狱咏蝉》"] +["别有相思处,啼鸟杂夜风。","尹式《别宋常侍》"] +["若问相思甚了期,除非相见时。","晏几道《长相思·长相思》"] +["家住层城临汉苑,心随明月到胡天。","皇甫冉《春思》"] +["谁见汀洲上,相思愁白蘋.。","刘长卿《饯别王十一南游》"] +["乡思不堪悲橘柚,旅游谁肯重王孙。","谭用之《秋宿湘江遇雨》"] +["汀洲无浪复无烟,楚客相思益渺然。","刘长卿《自夏口至鹦鹉洲夕望岳阳寄源中丞》"] +["梦里相思,故国王孙路。","陈子龙《点绛唇·春日风雨有感》"] +["尽教春思乱如云,莫管世情轻似絮。","晏几道《玉楼春·雕鞍好为莺花住》"] +["梅似雪,柳如丝。试听别语慰相思。","辛弃疾《鹧鸪天·送欧阳国瑞入吴中》"] +["纵明月相思千里隔。梦咫尺。勤书尺。","贺铸《琴调相思引·送范殿监赴黄岗》"] +["终日望君君不至,举头闻鹊喜。","冯延巳《谒金门·风乍起》"] +["绮席象床寒玉枕,美人何处醉黄花。","汪元量《望江南·幽州九日》"] +["君怀良不开,贱妾当何依。","曹植《明月上高楼》"] +["秾艳一枝细看取,芳心千重似束。","苏轼《贺新郎·夏景》"] +["问相思、他日镜中看,萧萧发。","严羽《满江红·送廖叔仁赴阙》"] +["君行逾十年,孤妾常独栖。","曹植《明月上高楼》"] +["怀春情不断,犹带相思旧子。","吴文英《丁香结·夷则商秋日海棠》"] +["念佳人、音尘别后,对此应解相思。","晁端礼《绿头鸭·咏月》"] +["我歌君起舞,潦倒略相同。","陈师道《除夜对酒赠少章》"] +["教人无处寄相思,落花芳草过前期,没人知。","孙光宪《虞美人·好风微揭帘旌起》"] +["旧相思、偏供闲昼。","吴文英《夜游宫·春语莺迷翠柳》"] +["意长翻恨游丝短。尽日相思罗带缓。","严仁《玉楼春·春思》"] +["云淡风高叶乱飞,小庭寒雨绿苔微,深闺人静掩屏帷。","顾敻《浣溪沙·云淡风高叶乱飞》"] +["绣袂捧琴兮,登君子堂。","贯休《善哉行·伤古曲无知音》"] +["明月照相思,也得姮娥念我痴。","高鹗《南乡子·戊申秋隽喜晤故人》"] +["春闺月,红袖不须啼。","元好问《江月晃重山·初到嵩山时作》"] +["垓下美人泣楚歌,定陶美人泣楚舞,真龙亦鼠虎亦鼠。","王象春《书项王庙壁》"] +["漫向寒炉醉玉瓶,唤君同赏小窗明。","陆游《浣溪沙·和无咎韵》"] +["忆与君别年,种桃齐蛾眉。","李白《独不见》"] +["宝奁明月不欺人,明日归来君试看。","严仁《玉楼春·春思》"] +["引之于山,兽不能走。吹之于水,鱼不能游。","牛殳《琵琶行》"] +["人道山长水又断。萧萧微雨闻孤馆。","李清照《蝶恋花·泪湿罗衣脂粉满》"] +["山映斜阳天接水。芳草无情,更在斜阳外。","范仲淹《苏幕遮·怀旧》"] +["轮台东门送君去,去时雪满天山路。","岑参《白雪歌送武判官归京》"] +["情知已被山遮断,频倚阑干不自由。","辛弃疾《鹧鸪天·晚日寒鸦一片愁》"] +["更吹羌笛关山月,无那金闺万里愁。","王昌龄《从军行七首》"] +["稻根科斗行如块,田水今年一尺宽。","范成大《夏日田园杂兴》"] +["鸟无声兮山寂寂,夜正长兮风淅淅。","李华《吊古战场文》"] +["君言不得意,归卧南山陲。","王维《送别》"] +["闻道梅花坼晓风,雪堆遍满四山中。","陆游《梅花绝句》"] +["认得醉翁语,山色有无中。","苏轼《水调歌头·黄州快哉亭赠张偓佺》"] +["长风萧萧渡水来,归雁连连映天没。","卢思道《从军行》"] +["斜阳流水推篷坐,翠色随人欲上船。","纪昀《富春至严陵山水甚佳》"] +["烟水茫茫,千里斜阳暮。山无数。乱红如雨。不记来时路。","秦观《点绛唇·桃源》"] +["半夜倚乔松,不觉满衣雪。","孟郊《苦寒吟》"] +["柴门闻犬吠,风雪夜归人。","刘长卿《逢雪宿芙蓉山主人》"] +["城雪初消荠菜生,角门深巷少人行。","杨载《到京师》"] +["天仙碧玉琼瑶,点点扬花,片片鹅毛。","薛昂夫《蟾宫曲·雪》"] +["已讶衾枕冷,复见窗户明。","白居易《夜雪》"] +["凄凄岁暮风,翳翳经日雪。","陶渊明《癸卯岁十二月中作与从弟敬远》"] +["野云万里无城郭,雨雪纷纷连大漠。","李颀《古从军行》"] +["不知庭霰今朝落,疑是林花昨夜开。","宋之问《苑中遇雪应制》"] +["黄河捧土尚可塞,北风雨雪恨难裁。","李白《北风行》"] +["问他有甚堪悲处?思量却也有悲时,重阳节近多风雨。","辛弃疾《踏莎行·庚戌中秋后二夕带湖篆冈小酌》"] +["今夕不登楼,一年空过秋。","高观国《菩萨蛮·何须急管吹云暝》"] +["梧桐雨细。渐滴作秋声,被风惊碎。","张辑《疏帘淡月·寓桂枝香秋思》"] +["一点残红欲尽时。乍凉秋气满屏帏。","周紫芝《鹧鸪天·一点残红欲尽时》"] +["萧萧落木不胜秋,莫回首、斜阳下。","纳兰性德《一络索·过尽遥山如画》"] +["白雪关山远,黄云海戍迷。","李白《紫骝马》"] +["雪中何以赠君别,惟有青青松树枝。","岑参《天山雪歌送萧治归京》"] +["天山三丈雪,岂是远行时。","李白《独不见》"] +["泛楼船兮济汾河,横中流兮扬素波。","刘彻《秋风辞》"] +["堪笑楚江空渺渺,不能洗得直臣冤。","文秀《端午》"] +["北风夜卷赤亭口,一夜天山雪更厚。","岑参《天山雪歌送萧治归京》"] +["昨夜醉眠西浦月。今宵独钓南溪雪。","洪适《渔家傲引·子月水寒风又烈》"] +["东船西舫悄无言,唯见江心秋月白。","白居易《琵琶行 / 琵琶引》"] +["不闻爷娘唤女声,但闻黄河流水鸣溅溅。","佚名《木兰诗 / 木兰辞》"] +["黯与山僧别,低头礼白云。","李白《秋浦歌十七首》"] +["拨云寻古道,倚石听流泉。","李白《寻雍尊师隐居》"] +["大风起兮云飞扬。威加海内兮归故乡。","刘邦《大风歌》"] +["风萧萧而异响,云漫漫而奇色。","江淹《别赋》"] +["碧虚无云风不起,山上长松山下水。","刘禹锡《八月十五夜桃源玩月》"] +["今宵楼上一尊同。云湿纱窗。","辛弃疾《一剪梅·中秋元月》"] +["快上西楼,怕天放、浮云遮月。","辛弃疾《满江红·中秋寄远》"] +["初惊河汉落,半洒云天里。","李白《望庐山瀑布水二首》"] +["涛澜汹涌,风云开阖。","苏辙《黄州快哉亭记》"] +["白日来深殿,青云满后尘。","杜甫《寄李十二白二十韵》"] +["云间连下榻,天上接行杯。","李白《与夏十二登岳阳楼》"] +["对潇潇暮雨洒江天,一番洗清秋。","柳永《八声甘州·对潇潇暮雨洒江天》"] +["万里浮云卷碧山,青天中道流孤月。","李白《答王十二寒夜独酌有怀》"] +["浮云暮南征,可望不可攀。","杜甫《前出塞九首》"] +["北山白云里,隐者自怡悦。","孟浩然《秋登兰山寄张五》"] +["清漏渐移相望久,微云未接过来迟。","李商隐《辛未七夕》"] +["春游之盛,西湖未能过也。","姜夔《琵琶仙·双桨来时》"] +["黄河九天上,人鬼瞰重关。","元好问《水调歌头·赋三门津》"] +["俯瞰黄河小,高悬白雪清。","胡秉正《咏贺兰山》"] +["赠君明月满前溪,直到西湖畔。","毛滂《烛影摇红·送会宗》"] +["长江万里东注,晓吹卷惊涛。","吴潜《水调歌头·焦山》"] +["黄芦掩映清江下,斜缆着钓鱼艖。","关汉卿《大德歌·冬景》"] +["黄河水,水阔无边深无底,其来不知几千里。","贡泰父《黄河行》"] +["江暗雨欲来,浪白风初起。","何逊《相送》"] +["江带峨眉雪,川横三峡流。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["蕙风如薰,甘露如醴。","左思《三都赋》"] +["积土成山,风雨兴焉;","荀子《劝学》"] +["北风吹白云,万里渡河汾。","苏颋《汾上惊秋》"] +["此时瞻白兔,直欲数秋毫。","杜甫《八月十五夜月二首》"] +["目断秋霄落雁,醉来时响空弦。","辛弃疾《木兰花慢·滁州送范倅》"] +["塞上秋风鼓角,城头落日旌旗。","元好问《江月晃重山·初到嵩山时作》"] +["荻花秋,潇湘夜,橘洲佳景如屏画。","李珣《渔歌子·荻花秋》"] +["自有多情处,明月挂南楼。","米芾《水调歌头·中秋》"] +["寒山几堵,风低削碎中原路,秋空一碧无今古。","陈维崧《醉落魄·咏鹰》"] +["采菱人语隔秋烟,波静如横练。","王恽《平湖乐·采菱人语隔秋烟》"] +["秋色从西来,苍然满关中。","岑参《与高适薛据同登慈恩寺浮图》"] +["红蓼渡头秋正雨,印沙鸥迹自成行,整鬟飘袖野风香。","薛昭蕴《浣溪沙·红蓼渡头秋正雨》"] +["天长雁影稀,月落山容瘦,冷清清暮秋时候。","关汉卿《沉醉东风·重九》"] +["夕阳鸟外,秋风原上,目断四天垂。","柳永《少年游·长安古道马迟迟》"] +["秋风万里动,日暮黄云高。","岑参《巩北秋兴寄崔明允》"] +["秋色渐将晚,霜信报黄花。","叶梦得《水调歌头·秋色渐将晚》"] +["何处秋风至?萧萧送雁群。","刘禹锡《秋风引》"] +["秋菊有佳色,裛露掇其英。","陶渊明《饮酒·其四》"] +["雨色秋来寒,风严清江爽。","李白《酬裴侍御对雨感时见赠》"] +["信宿渔人还泛泛,清秋燕子故飞飞。","杜甫《秋兴八首》"] +["芙蓉生在秋江上,不向东风怨未开。","高蟾《下第后上永崇高侍郎》"] +["酒盏旋将荷叶当。莲舟荡。时时盏里生红浪。","欧阳修《渔家傲·花底忽闻敲两桨》"] +["榈庭多落叶,慨然知已秋。","陶渊明《酬刘柴桑》"] +["涧影见松竹,潭香闻芰荷。","孟浩然《夏日浮舟过陈大水亭 / 浮舟过滕逸人别业》"] +["雨停荷芰逗浓香,岸边蝉噪垂杨。","阎选《临江仙·雨停荷芰逗浓香》"] +["桑野就耕父,荷锄随牧童。","孟浩然《田家元日》"] +["芰荷迭映蔚,蒲稗相因依。","谢灵运《石壁精舍还湖中作》"] +["莲子已成荷叶老。青露洗、萍花汀草。","李清照《怨王孙·湖上风来波浩渺》"] +["一春鱼雁无消息,则见双燕斗衔泥。","关汉卿《大德歌·春》"] +["二月巴陵日日风,春寒未了怯园公。","陈与义《春寒》"] +["不肯画堂朱户,春风自在杨花。","王安国《清平乐·春晚》"] +["柳丝长,春雨细,花外漏声迢递。","温庭筠《更漏子·柳丝长》"] +["屋上春鸠鸣,村边杏花白。","王维《春中田园作》"] +["为报今年春色好。花光月影宜相照。","李清照《蝶恋花·上巳召亲族》"] +["大堤欲上谁相伴,马踏春泥半是花。","窦巩《襄阳寒食寄宇文籍》"] +["昨夜江边春水生,艨艟巨舰一毛轻。","朱熹《活水亭观书有感二首·其二》"] +["春未老,风细柳斜斜。","苏轼《望江南·超然台作》"] +["长江一帆远,落日五湖春。","刘长卿《饯别王十一南游》"] +["莺啼燕语报新年,马邑龙堆路几千。","皇甫冉《春思》"] +["卷尽残花风未定,休恨,花开元自要春风。","辛弃疾《定风波·暮春漫兴》"] +["露湿晴花春殿香,月明歌吹在昭阳。","李益《宫怨》"] +["二月春花厌落梅。仙源归路碧桃催。","晏几道《浣溪沙·二月春花厌落梅》"] +["首夏犹清和,芳草亦未歇。","谢灵运《游赤石进帆海》"] +["东风不管琵琶怨。落花吹遍。","许棐《后庭花·一春不识西湖面》"] +["小桃灼灼柳鬖鬖,春色满江南。","黄庭坚《诉衷情·小桃灼灼柳鬖鬖》"] +["两株桃杏映篱斜,妆点商州副使家。","王禹偁《春居杂兴·两株桃杏映篱斜》"] +["绿波春浪满前陂,极目连云稏肥。","韦庄《稻田》"] +["日长睡起无情思,闲看儿童捉柳花。","杨万里《闲居初夏午睡起》"] +["柴门寂寂黍饭馨,山家烟火春雨晴。","贯休《春晚书山家屋壁二首》"] +["四顾山光接水光,凭栏十里芰荷香。","黄庭坚《鄂州南楼书事》"] +["秀色掩今古,荷花羞玉颜。","李白《西施 / 咏苎萝山》"] +["海燕未来人斗草,江梅已过柳生绵。","李清照《浣溪沙·淡荡春光寒食天》"] +["闹花深处层楼,画帘半卷东风软。","陈亮《水龙吟·春恨》"] +["京国多年情尽改,忽听春雨忆江南。","虞集《听雨》"] +["屈指数春来,弹指惊春去。","高观国《卜算子·泛西湖坐间寅斋同赋》"] +["春草细还生,春雏养渐成。","揭傒斯《画鸭》"] +["芳树笼秦栈,春流绕蜀城。","李白《送友人入蜀》"] +["何事春风容不得?和莺吹折数枝花。","王禹偁《春居杂兴·两株桃杏映篱斜》"] +["春色将阑,莺声渐老。红英落尽青梅小。","寇准《踏莎行·春色将阑》"] +["今岁今宵尽,明年明日催。","史青《应诏赋得除夜》"] +["经年尘土满征衣,特特寻芳上翠微。","岳飞《池州翠微亭》"] +["晚岁登门最不才,萧萧华发映金罍。","苏洵《九日和韩魏公》"] +["玉颜憔悴三年,谁复商量管弦。","王建《宫中调笑·团扇》"] +["今年海角天涯。萧萧两鬓生华。","李清照《清平乐·年年雪里》"] +["一自胡尘入汉关,十年伊洛路漫漫。","陈与义《咏牡丹》"] +["丈夫志,当景盛,耻疏闲。","苏舜钦《水调歌头·沧浪亭》"] +["指点江山,激扬文字,粪土当年万户侯。","毛泽东《沁园春·长沙》"] +["痴儿不知父子礼,叫怒索饭啼门东。","杜甫《百忧集行》"] +["雌雄空中鸣,声尽呼不归。","白居易《燕诗示刘叟》"] +["蔡女菱歌移锦缆,燕姬春望上琼钩。","上官仪《咏画障》"] +["自闻颖师弹,起坐在一旁。","韩愈《听颖师弹琴》"] +["来时父母知隔生,重著衣裳如送死。","王建《渡辽水》"] +["辛勤三十日,母瘦雏渐肥。","白居易《燕诗示刘叟》"] +["牛群食草莫相触,官家截尔头上角。","李涉《牧童词》"] +["雨余溪水掠堤平,闲看村童谢晚晴。","陆游《观村童戏溪上》"] +["半夜呼儿趁晓耕,羸牛无力渐艰行。","颜仁郁《农家》"] +["儿童强不睡,相守夜欢哗。","苏轼《守岁》"] +["牛羊自归村巷,童稚不识衣冠。","王维《田园乐七首·其四》"] +["相携及田家,童稚开荆扉。","李白《下终南山过斛斯山人宿置酒》"] +["想见读书头已白,隔溪猿哭瘴溪藤。","黄庭坚《寄黄几复》"] +["岁暮锄犁傍空室,呼儿登山收橡实。","张籍《野老歌 / 山农词》"] +["擢擢当轩竹,青青重岁寒。","吕太一《咏院中丛竹》"] +["华表月明归夜鹤,叹当时、花竹今如此。","吴文英《金缕歌·陪履斋先生沧浪看梅》"] +["龟游莲叶上,鸟宿芦花里。","李白《姑孰十咏》"] +["霜风捣尽千林叶,闲倚筇枝数鹳巢。","范成大《冬日田园杂兴》"] +["九月寒砧催木叶,十年征戍忆辽阳。","沈佺期《古意呈补阙乔知之 / 古意 / 独不见》"] +["芄兰之叶,童子佩韘。","佚名《芄兰》"] +["蒲生我池中,其叶何离离。","甄宓《塘上行》"] +["承恩不在貌,教妾若为容。","杜荀鹤《春宫怨》"] +["香脸半开娇旖旎。当庭际。玉人浴出新妆洗。","李清照《渔家傲·雪里已知春信至》"] +["辽东小妇年十五,惯弹琵琶解歌舞。","李颀《古意》"] +["夫因兵死守蓬茅,麻苎衣衫鬓发焦。","杜荀鹤《山中寡妇 / 时世行》"] +["天公见玉女,大笑亿千场。","李白《短歌行》"] +["谁家女儿对门居,开颜发艳照里闾。","萧衍《东飞伯劳歌》"] +["佳人应怪我,别后寡信轻诺。","柳永《尾犯·夜雨滴空阶》"] +["不信楼头杨柳月,玉人歌舞未曾归。","谢枋得《蚕妇吟》"] +["未成林,难望凤来栖,聊医俗。","陆容《满江红·咏竹》"] +["接汉疑星落,依楼似月悬。","卢照邻《十五夜观灯》"] +["谁爱风流高格调,共怜时世俭梳妆。","秦韬玉《贫女》"] +["更被夕阳江岸上,断肠烟柳一丝丝。","韦庄《江外思乡》"] +["见杨柳飞绵滚滚,对桃花醉脸醺醺。","王实甫《十二月过尧民歌·别情》"] +["虽惭老圃秋容淡,且看黄花晚节香。","韩琦《九日水阁》"] +["对兹佳品酬佳节,桂拂清风菊带霜。","曹雪芹《螃蟹咏》"] +["西风酒旗市,细雨菊花天。","欧阳修《秋怀》"] +["泛菊杯深,吹梅角远,同在京城。","刘过《柳梢青·送卢梅坡》"] +["尘世难逢一笑,况有紫萸黄菊,堪插满头归。","朱熹《水调歌头·隐括杜牧之齐山诗》"] +["未追赤松子,且泛黄菊英。","李泌《奉和圣制重阳赐会聊示所怀》"] +["菊花信待重阳久,桂子香闻上界留。","慧霖《闰中秋玩月》"] +["台高不尽看枫叶,院净何须坐菊花。","沈辂《九日登高台寺》"] +["故园肠断处,日夜柳条新。","宋之问《途中寒食题黄梅临江驿寄崔融》"] +["枝枝相覆盖,叶叶相交通。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["枣花至小能成实,桑叶虽柔解吐丝。","王溥《咏牡丹》"] +["柳色黄金嫩,梨花白雪香。","李白《宫中行乐词八首》"] +["楼前绿暗分携路,一丝柳、一寸柔情。","吴文英《风入松·听风听雨过清明》"] +["柳岸风来影渐疏,使君家似野人居。","杜牧《齐安郡晚秋》"] +["梅落繁枝千万片,犹自多情,学雪随风转。","冯延巳《鹊踏枝·梅落繁枝千万片》"] +["白梅懒赋赋红梅,逞艳先迎醉眼开。","曹雪芹《咏红梅花得“梅”字》"] +["去岁江南见雪时,月底梅花发。","张孝祥《卜算子·雪月最相宜》"] +["桃花落后蚕齐浴,竹笋抽时燕便来。","朱彝尊《鸳鸯湖棹歌·一百首选二》"] +["笑时犹带岭梅香。试问岭南应不好。","苏轼《定风波·南海归赠王定国侍人寓娘》"] +["菡萏香连十顷陂,小姑贪戏采莲迟。","皇甫松《采莲子·菡萏香莲十顷陂》"] +["当轩对尊酒,四面芙蓉开。","王维《临湖亭》"] +["露清枕簟藕花香,恨悠扬。","顾敻《虞美人·触帘风送景阳钟》"] +["芙蓉老秋霜,团扇羞网尘。","李白《中山孺子妾歌》"] +["东望鞭芙缥缈,寒光如注。","刘辰翁《桂枝香·吹箫人去》"] +["柳下桃蹊,乱分春色到人家。","秦观《望海潮·洛阳怀古》"] +["春江暖涨桃花水。画舫珠帘,载酒东风里。","顾德辉《蝶恋花·春江暖涨桃花水》"] +["画阁朱楼尽相望,红桃绿柳垂檐向。","王维《洛阳女儿行》"] +["桃花嫣然出篱笑,似开未开最有情。","汪藻《春日·一春略无十日晴》"] +["日高烟敛,更看今日晴未。","李清照《念奴娇·春情》"] +["竹里缲丝挑网车,青蝉独噪日光斜。","李贺《南园十三首》"] +["日暖桑麻光似泼,风来蒿艾气如薰。","苏轼《浣溪沙·软草平莎过雨新》"] +["社下烧钱鼓似雷,日斜扶得醉翁回。","范成大《春日田园杂兴》"] +["胡马嘶风,汉旗翻雪,彤云又吐,一竿残照。","时彦《青门饮·寄宠人》"] +["迎醉面,暮雪飞花,几点黛愁山暝。","吴文英《十二郎·垂虹桥》"] +["日光下澈,影布石上,佁然不动;","柳宗元《小石潭记》"] +["任宝奁尘满,日上帘钩。","李清照《凤凰台上忆吹箫·香冷金猊》"] +["牛困人饥日已高,市南门外泥中歇。","白居易《卖炭翁》"] +["落月满屋梁,犹疑照颜色。","杜甫《梦李白二首·其一》"] +["斜月沉沉藏海雾,碣石潇湘无限路。","张若虚《春江花月夜》"] +["古刹疏钟度,遥岚破月悬。","李贺《南园十三首》"] +["小池寒绿欲生漪,雨晴还日西。","佚名《阮郎归·春风吹雨绕残枝》"] +["明日客程还几许,沾衣况是新寒雨。","纳兰性德《蝶恋花·又到绿杨曾折处》"] +["雨中百草秋烂死,阶下决明颜色鲜。","杜甫《秋雨叹三首》"] +["升堂坐阶新雨足,芭蕉叶大栀子肥。","韩愈《山石》"] +["何处飞来林间鹊,蹙踏松梢微雪。","辛弃疾《贺新郎·把酒长亭说》"] +["轻淘起,香生玉尘,雪溅紫瓯圆。","米芾《满庭芳·咏茶》"] +["驱马天雨雪,军行入高山。","杜甫《前出塞九首》"] +["晓鸡惊树雪,寒鹜守冰池。","李商隐《幽居冬暮》"] +["关山正飞雪,烽火断无烟。","王维《陇西行》"] +["今我来思,雨雪载途。","佚名《出车》"] +["增冰峨峨,飞雪千里些。","屈原《招魂》"] +["拨雪寻春,烧灯续昼。","毛滂《踏莎行·元夕》"] +["雨雪自飞千嶂外,榆林只隔数峰西。","朱彝尊《出居庸关》"] +["雪雰雰而薄木兮,云霏霏而陨集。","刘向《九叹》"] +["朔方正月雪澌澌,单于策马至京西。","沈约《昭君辞》"] +["黄雾涨天雪晦冥,黑云拂地风膻腥。","沈约《昭君辞》"] +["辔摇衔铁。蹴踏平原雪。","张炎《清平乐·平原放马》"] +["美人如花隔云端。","李白《长相思三首》"] +["城阙辅三秦,风烟望五津。","王勃《送杜少府之任蜀州 / 送杜少府之任蜀川》"] +["但去莫复问,白云无尽时。","王维《送别》"] +["仙人抚我顶,结发受长生。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["红颜弃轩冕,白首卧松云。","李白《赠孟浩然》"] +["行人刁斗风沙暗,公主琵琶幽怨多。","李颀《古从军行》"] +["且乐杯中物,谁论世上名。","孟浩然《自洛之越》"] +["人事有代谢,往来成古今。","孟浩然《与诸子登岘山》"] +["烹羊宰牛且为乐,会须一饮三百杯。","李白《将进酒》"] +["世人皆欲杀,吾意独怜才。","杜甫《不见》"] +["花近高楼伤客心,万方多难此登临。","杜甫《登楼》"] +["功盖三分国,名成八阵图。","杜甫《八阵图》"] +["绝代有佳人,幽居在空谷。","杜甫《佳人》"] +["三月三日天气新,长安水边多丽人。","杜甫《丽人行》"] +["花迎剑珮星初落,柳拂旌旗露未干。","岑参《奉和中书舍人贾至早朝大明宫》"] +["青天有月来几时?我今停杯一问之。","李白《把酒问月·故人贾淳令予问之》"] +["昔在长安醉花柳,五侯七贵同杯酒。","李白《流夜郎赠辛判官》"] +["渡远荆门外,来从楚国游。","李白《渡荆门送别》"] +["料得年年肠断处,明月夜,短松冈。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["千里孤坟,无处话凄凉。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["春已老,春服几时成。","苏轼《望江南·暮春》"] +["多情却被无情恼。","苏轼《蝶恋花·春景》"] +["三过平山堂下,半生弹指声中。","苏轼《西江月·平山堂》"] +["关山魂梦长,鱼雁音尘少。","晏几道《生查子·关山魂梦长》"] +["把酒祝东风,且共从容。","欧阳修《浪淘沙·把酒祝东风》"] +["往事悠悠君莫问,回头。","王安石《南乡子·自古帝王州》"] +["草色烟光残照里,无言谁会凭阑意。","柳永《蝶恋花·伫倚危楼风细细》"] +["云树绕堤沙,怒涛卷霜雪,天堑无涯。","柳永《望海潮·东南形胜》"] +["弃捐勿复道,努力加餐饭。","佚名《行行重行行》"] +["客从远方来,遗我双鲤鱼。","佚名《饮马长城窟行》"] +["战城南,死郭北,野死不葬乌可食。","佚名《战城南》"] +["青青河畔草,绵绵思远道。","佚名《饮马长城窟行》"] +["枯桑知天风,海水知天寒。","佚名《饮马长城窟行》"] +["离恨恰如春草,更行更远还生。","李煜《清平乐·别来春半》"] +["节分端午自谁言,万古传闻为屈原。","文秀《端午》"] +["云日相晖映,空水共澄鲜。","谢灵运《登江中孤屿》"] +["丈夫志四海,万里犹比邻。","曹植《赠白马王彪·并序》"] +["折戟沉沙铁未销,自将磨洗认前朝。","杜牧《赤壁》"] +["男儿屈穷心不穷,枯荣不等嗔天公。","李贺《野歌》"] +["如何四纪为天子,不及卢家有莫愁。","李商隐《马嵬二首》"] +["不知腐鼠成滋味,猜意鹓雏竟未休。","李商隐《安定城楼》"] +["西施若解倾吴国,越国亡来又是谁。","罗隐《西施》"] +["树头蜂抱花须落,池面鱼吹柳絮行。","韩偓《残春旅舍》"] +["天上碧桃和露种,日边红杏倚云栽。","高蟾《下第后上永崇高侍郎》"] +["昔日龌龊不足夸,今朝放荡思无涯。","孟郊《登科后》"] +["报君黄金台上意,提携玉龙为君死!","李贺《雁门太守行》"] +["飞光飞光,劝尔一杯酒。","李贺《苦昼短》"] +["种桃道士归何处,前度刘郎今又来。","刘禹锡《再游玄都观》"] +["莫道谗言如浪深,莫言迁客似沙沉。","刘禹锡《浪淘沙九首》"] +["三日入厨下,洗手作羹汤。","王建《新嫁娘词三首》"] +["曲终人不见,江上数峰青。","钱起《省试湘灵鼓瑟》"] +["天行有常,不为尧存,不为桀亡。应之以治则吉,应之以乱则凶。","《荀子·天论》"] +["志之难也,不在胜人,在自胜。","《韩非子·喻老》"] +["井蛙不可以语于海者,拘于虚也;夏虫不可以语于冰者,笃于时也","《庄子·外篇·秋水》"] +["肌肤若冰雪,淖约若处子;不食五谷,吸风饮露;乘云气,御飞龙,而游乎四海之外","《庄子·内篇·逍遥游》"] +["二人同心,其利断金。同心之言,其臭如兰。","《易传·系辞传上·第八章》"] +["千羊之皮,不如一狐之腋。","《史记·赵世家》"] +["敏而好学,不耻下问,是以谓之文也。","《论语·公冶长篇》"] +["见贤思齐焉,见不贤而内自省也。","《论语·里仁篇》"] +["君子不器。","《论语·为政篇》"] +["'君子不蔽人之美,不言人之恶。","《韩非子·内储说上七术》"] +["天将降大任于是人也,必先苦其心志,劳其筋骨,饿其体肤,空乏其身,行拂乱其所为。","《孟子·告子章句下·第十五节》"] +["劝君莫打枝头鸟,子在巢中望母归。","白居易《鸟》"] +["太平待诏归来日,朕与先生解战袍。","朱厚熜《送毛伯温》"] +["一蓑一笠一扁舟,一丈丝纶一寸钩。","王士祯《题秋江独钓图》"] +["一曲高歌一樽酒,一人独钓一江秋。","王士祯《题秋江独钓图》"] +["破帽遮颜过闹市,漏船载酒泛中流。","鲁迅《自嘲》"] +["管城子无食肉相,孔方兄有绝交书。","黄庭坚《戏呈孔毅父》"] +["鹰击长空,鱼翔浅底,万类霜天竞自由。","毛泽东《沁园春·长沙》"] +["夜热依然午热同,开门小立月明中。","杨万里《夏夜追凉》"] +["待到山花烂漫时,她在丛中笑。","毛泽东《卜算子·咏梅》"] +["而今才道当时错,心绪凄迷。","纳兰性德《采桑子·当时错》"] +["郎笑藕丝长,长丝藕笑郎。","苏轼《菩萨蛮·回文夏闺怨》"] +["谷雨清明空屈指。","苏轼《天仙子·走马探花花发未》"] +["少年侠气,交结五都雄。","贺铸《六州歌头·少年侠气》"] +["莫学班超投笔,纵得封侯万里,憔悴老边州。","辛弃疾《水调歌头·落日古城角》"] +["八百里分麾下炙,五十弦翻塞外声。","辛弃疾《破阵子·为陈同甫赋壮词以寄之》"] +["争渡,争渡,惊起一滩鸥鹭。","李清照《如梦令·常记溪亭日暮》"] +["闻说双溪春尚好,也拟泛轻舟。","李清照《武陵春·春晚》"] +["凤箫声动,玉壶光转,一夜鱼龙舞。","辛弃疾《青玉案·元夕》"] +["洞庭青草,近中秋,更无一点风色。","张孝祥《念奴娇·过洞庭》"] +["几人平地上,看我碧霄中。","侯蒙《临江仙·未遇行藏谁肯信》"] +["念桥边红药,年年知为谁生。","姜夔《扬州慢·淮左名都》"] +["此中有真意,欲辨已忘言。","陶渊明《饮酒·其五》"] +["名花倾国两相欢,常得君王带笑看。","李白《清平调·名花倾国两相欢》"] +["万一禅关砉然破,美人如玉剑如虹。","龚自珍《夜坐二首》"] +["者是春山魂一片,招入孤舟。","左辅《浪淘沙·曹溪驿折桃花一枝数日零落裹花片投之涪江歌此送之》"] +["春花秋月何时了?往事知多少。","李煜《虞美人·春花秋月何时了》"] +["江东子弟今虽在,肯与君王卷土来?","王安石《叠题乌江亭》"] +["何方可化身千亿,一树梅花一放翁。","陆游《梅花绝句·其一》"] +["致虚,极;守静篤。万物并作,吾以观复。","《老子·道经·第十六章》"] +["责人之心责己,恕己之心恕人。","《增广贤文·上集》"] +["使人有乍交之欢,不若使人无久处之厌。","《小窗幽记·集醒篇》"] +["市恩不如报德之为厚,要誉不如逃名之为适,矫情不如直节之为真。","《小窗幽记·集醒篇》"] +["小知不可使谋事,小忠不可使主法。","《韩非子·饰邪》"] +["暗昧处见光明世界,此心即白日青天。","《围炉夜话·第七八则》"] +["人恶人怕天不怕,人善人欺天不欺。","《增广贤文·上集》"] +["圣人自知不自见;自爱不自贵。","《老子·德经·第七十二章》"] +["但愿苍生俱饱暖,不辞辛苦出山林。","于谦《咏煤炭》"] +["熊鱼自笑贪心甚,既要工诗又怕穷。","赵翼《论诗五首》"] +["仙佛茫茫两未成,只知独夜不平鸣。","黄景仁《杂感》"] +["十有九人堪白眼,百无—用是书生。","黄景仁《杂感》"] +["世味年来薄似纱,谁令骑马客京华。","陆游《临安春雨初霁》"] +["从今若许闲乘月,拄杖无时夜叩门。","陆游《游山西村》"] +["日长似岁闲方觉,事大如天醉亦休。","陆游《秋思》"] +["避席畏闻文字狱,著书都为稻粱谋。","龚自珍《咏史》"] +["是非成败转头空。","杨慎《临江仙·滚滚长江东逝水》"] +["一壶浊酒喜相逢。","杨慎《临江仙·滚滚长江东逝水》"] +["万物静观皆自得,四时佳兴与人同。","程颢《秋日》"] +["白露收残月,清风散晓霞。","仲殊《南柯子·十里青山远》"] +["金炉香烬漏声残,翦翦轻风阵阵寒。","王安石《春夜》"] +["时人不识余心乐,将谓偷闲学少年。","程颢《春日偶成》"] +["刘郎已恨蓬山远,更隔蓬山一万重!","李商隐《无题·来是空言去绝踪》"] +["精卫衔微木,将以填沧海。","陶渊明《读山海经·其十》"] +["溪柴火软蛮毡暖,我与狸奴不出门。","陆游《十一月四日风雨大作二首》"] +["缕衣檀板无颜色,一曲当时动帝王。","刘子翚《汴京纪事》"] +["篱落疏疏一径深,树头花落未成阴。","杨万里《宿新市徐公店》"] +["劝君今夜须沉醉,尊前莫话明朝事。","韦庄《菩萨蛮·劝君今夜须沉醉》"] +["爱惜芳心莫轻吐, 且教桃李闹春风。","元好问《同儿辈赋未开海棠》"] +["怪生无雨都张伞,不是遮头是使风。","杨万里《舟过安仁》"] +["低徊愧人子,不敢叹风尘。","蒋士铨《岁暮到家 / 岁末到家》"] +["昨夜裙带解,今朝蟢子飞。","权德舆《玉台体》"] +["等是有家归未得,杜鹃休向耳边啼。","佚名《杂诗》"] +["长醉后方何碍,不醒时有甚思。","白朴《寄生草·饮》"] +["目送归鸿,手挥五弦。","嵇康《赠秀才入军·其十四》"] +["煮豆燃豆萁,豆在釜中泣。","曹植《七步诗》"] +["西京乱无象,豺虎方遘患。","王粲《七哀诗三首·其一》"] +["夜中不能寐,起坐弹鸣琴。","阮籍《咏怀八十二首·其一》"] +["铁马晓嘶营壁冷,楼船夜渡风涛急。","刘克庄《满江红·夜雨凉甚忽动从戎之兴》"] +["旁观拍手笑疏狂。","刘克庄《一剪梅·余赴广东实之夜饯于风亭》"] +["长亭长在眼,更重重、远水孤云。","韩缜《凤箫吟·锁离愁》"] +["霞散绮,月沉钩,帘卷未央楼。","夏竦《喜迁莺·霞散绮》"] +["自胡马窥江去后,废池乔木,犹厌言兵。","姜夔《扬州慢·淮左名都》"] +["试举头、一笑问青天,天无语。","吴潜《满江红·送李御带珙》"] +["遥岑远目,献愁供恨,玉簪螺髻。","辛弃疾《水龙吟·登建康赏心亭》"] +["凭谁问,万里长鲸吞吐,人间儿戏千弩。","辛弃疾《摸鱼儿·观潮上叶丞相》"] +["唤起九歌忠愤,拂拭三闾文字,还与日争光。","张孝祥《水调歌头·泛湘江》"] +["酒徒一半取封侯,独去作、江边渔父。","陆游《鹊桥仙·华灯纵博》"] +["波神留我看斜阳,放起鳞鳞细浪。","张孝祥《西江月·阻风山峰下》"] +["试问卷帘人,却道海棠依旧。","李清照《如梦令·昨夜雨疏风骤》"] +["此夜江边,月暗长堤柳暗船。","吕本中《减字木兰花·去年今夜》"] +["今年何以报君恩,一路繁花相送过青墩。","陈与义《虞美人·扁舟三日秋塘路》"] +["欲上高楼去避愁,愁还随我上高楼。","辛弃疾《鹧鸪天·欲上高楼去避愁》"] +["鹏北海,凤朝阳。","辛弃疾《鹧鸪天·送廓之秋试》"] +["千古兴亡,百年悲笑,一时登览。","辛弃疾《水龙吟·过南剑双溪楼》"] +["茅檐低小,溪上青青草。","辛弃疾《清平乐·村居》"] +["连云松竹,万事从今足。","辛弃疾《清平乐·检校山园书所见》"] +["可堪回首,佛狸祠下,一片神鸦社鼓。","辛弃疾《永遇乐·京口北固亭怀古》"] +["斜阳草树,寻常巷陌,人道寄奴曾住。","辛弃疾《永遇乐·京口北固亭怀古》"] +["少年情事老来悲。","姜夔《鹧鸪天·正月十一日观灯》"] +["雪沫乳花浮午盏,蓼茸蒿笋试春盘。","苏轼《浣溪沙·细雨斜风作晓寒》"] +["同来何事不同归。","贺铸《半死桐·重过阊门万事非》"] +["君知否?乱鸦啼后,归兴浓于酒。","汪藻《点绛唇·新月娟娟》"] +["起瞰高城回望,寥落关河千里,一醉与君同。","叶梦得《水调歌头·九月望日与客习射西园余偶病不能射》"] +["有桃花红,李花白,菜花黄。","秦观《行香子·树绕村庄》"] +["梅英疏淡,冰澌溶泄,东风暗换年华。","秦观《望海潮·洛阳怀古》"] +["四面垂杨十里荷,问云何处最花多。","苏轼《浣溪沙·荷花》"] +["竹篱茅舍出青黄。","苏轼《浣溪沙·咏橘》"] +["叹隙中驹,石中火,梦中身。","苏轼《行香子·述怀》"] +["燕子楼空,佳人何在,空锁楼中燕。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["只恐花深里,红露湿人衣。","黄庭坚《水调歌头·游览》"] +["病绪厌厌,浑似年时个。","苏轼《蝶恋花·雨霰疏疏经泼火》"] +["多少恨,昨夜梦魂中。","李煜《忆江南·多少恨》"] +["曾经沧海,又来沙漠,四千里外关河。","谭嗣同《望海潮·自题小影》"] +["成败之迹,昭哉可观。","韩愈《子产不毁乡校颂》"] +["明日复明日,明日何其多。","钱福《明日歌》"] +["君看一叶舟,出没风波里。","范仲淹《江上渔者》"] +["来日苦短,去日苦长。","陆机《短歌行》"] +["一语天然万古新,豪华落尽见真淳。","元好问《论诗三十首·其四》"] +["若容相访饮牛津,相对忘贫。","纳兰性德《画堂春·一生一代一双人》"] +["一叶叶,一声声,空阶滴到明。","温庭筠《更漏子·玉炉香》"] +["舟中贾客莫漫狂,小姑前年嫁彭郎。","苏轼《李思训画长江绝岛图》"] +["不敢高声语,恐惊天上人。","李白《夜宿山寺》"] +["九死南荒吾不恨,兹游奇绝冠平生。","苏轼《六月二十日夜渡海》"] +["一代天骄,成吉思汗,只识弯弓射大雕。","毛泽东《沁园春·雪》"] +["俱往矣,数风流人物,还看今朝。","毛泽东《沁园春·雪》"] +["画堂晨起,来报雪花坠。","李白《清平乐·画堂晨起》"] +["唧唧复唧唧,木兰当户织。","佚名《木兰诗 / 木兰辞》"] +["罗衾不耐五更寒。","李煜《浪淘沙令·帘外雨潺潺》"] +["刚被太阳收拾去,却教明月送将来。","苏轼《花影》"] +["日暮笙歌收拾去,万株杨柳属流莺。","吴惟信《苏堤清明即事》"] +["日斜吾事毕,一笑向杯盘。","陆游《乙卯重五诗》"] +["玉楼天半起笙歌,风送宫嫔笑语和。","顾况《宫词五首·其二》"] +["虽是一般,惟高一着。","王旭《踏莎行·雪中看梅花》"] +["自我来黄州,已过三寒食。","苏轼《寒食雨二首》"] +["宜雨宜晴,宜西施淡抹浓妆。","奥敦周卿《蟾宫曲·咏西湖》"] +["别有根芽,不是人间富贵花。","纳兰性德《采桑子·塞上咏雪花》"] +["文章功用不经世,何异丝窠缀露珠。","黄庭坚《戏呈孔毅父》"] +["明月多情应笑我,笑我如今。","纳兰性德《采桑子·明月多情应笑我》"] +["梨花淡白柳深青,柳絮飞时花满城。","苏轼《东栏梨花》"] +["运交华盖欲何求,未敢翻身已碰头。","鲁迅《自嘲》"] +["还睡、还睡,解道醒来无味。","纳兰性德《如梦令·万帐穹庐人醉》"] +["独行穿落叶,闲坐数流萤。","林景熙《溪亭》"] +["山中何事?松花酿酒,春水煎茶。","张可久《人月圆·山中书事》"] +["江南佳丽地,金陵帝王州。","谢朓《入朝曲》"] +["罗浮山下四时春,卢橘杨梅次第新。","苏轼《惠州一绝 / 食荔枝》"] +["高楼谁与上?长记秋晴望。","李煜《子夜歌·人生愁恨何能免》"] +["闲云不成雨,故傍碧山飞。","陆游《柳桥晚眺》"] +["苏州司业诗名老,乐府皆言妙入神。","王安石《题张司业诗》"] +["八方各异气,千里殊风雨。","曹植《泰山梁甫行》"] +["上马击狂胡,下马草军书。","陆游《观大散关图有感》"] +["纵浪大化中,不喜亦不惧。","陶渊明《形影神三首》"] +["白日沦西河,素月出东岭。","陶渊明《杂诗十二首·其二》"] +["高树鹊衔巢,斜月明寒草。","冯延巳《醉花间·晴雪小园春未到》"] +["文章本天成,妙手偶得之。","陆游《文章》"] +["一片冷香唯有梦,十分清瘦更无诗。","纳兰性德《梦江南·新来好》"] +["仁者如射,射者正己而后发。发而不中,不怨胜己者,反求诸己而已矣。","《孟子·公孙丑章句上·第七节》"] +["当断不断,反受其乱。","《史记·齐悼惠王世家》"] +["兼听则明,偏信则暗。","《资治通鉴·唐纪八》"] +["天下熙熙,皆为利来;天下壤壤,皆为利往。","《史记·货殖列传》"] +["富贵不能淫,贫贱不能移,威武不能屈。","《孟子·滕文公章句下·第二节》"] +["宁为兰摧玉折,不作萧敷艾荣。","《世说新语·言语》"] +["吾本乘兴而行,兴尽而返,何必见戴!","《世说新语·任诞》"] +["君子务知大者、远者,小人务知小者、近者。","《左传·襄公·襄公三十一年》"] +["非我族类,其心必异。","《左传·成公·成公四年》"] +["夫妻者,非有骨肉之恩也。爱则亲,不爱则疏。","《韩非子·备内》"] +["不期修古,不法常可。","《韩非子·五蠹》"] +["外举不避仇,内举不避子。","《韩非子·外储说左下》"] +["爱弃发之费,而忘长发之利,不知权者也。","《韩非子·六反》"] +["巧诈不如拙诚。","《韩非子·说林上》"] +["糟糠不饱者不务粱肉,短褐不完者不待文绣。","《韩非子·五蠹》"] +["用之在于机,显之在于势。","《六韬·文韬·兵道》"] +["圣王者不贵义而贵法,法必明,令必行,则已矣。","《商君书·画策》"] +["胜而不骄,败而不怨。","《商君书·战法》"] +["耕田欲雨刈欲晴,去得顺风来者怨。","苏轼《泗州僧伽塔》"] +["君子泰而不骄,小人骄而不泰。","《论语·子路篇》"] +["投之亡地然后存,陷之死地然后生。","《孙子兵法·九地篇》"] +["以三寸之舌,彊於百万之师。","《史记·平原君虞卿列传》"] +["血染征袍透甲红,当阳谁敢与争锋!古来冲阵扶危主,只有常山赵子龙。","《三国演义·第四十一回》"] +["菩萨妖精,总是一念。若论本来,皆属无有。","《西游记·第十七回》"] +["从善如登,从恶如崩","《资治通鉴·魏纪·魏纪三》"] +["无人调护,自去经心。","《浮生六记·闺房记乐》"] +["岁不寒无以知松柏,事不难无以知君子","《荀子·大略》"] +["彼窃钩者诛,窃国者为诸侯,诸侯之门而仁义存焉","《庄子·外篇·胠箧》"] +["天不为人之恶寒也辍冬,地不为人之恶辽远也辍广,君子不为小人之匈匈也辍行。","《荀子·天论》"] +["火形严,故人献灼;水形懦,人多溺。","《韩非子·内储说上七术》"] +["不以智累心,不以私累己;寄治乱于法术,托是非于赏罚。","《韩非子·大体》"] +["以人言善我,必以人言罪我。","《韩非子·说林上》"] +["何用别寻方外去,人间亦自有丹丘。","韩翃《同题仙游观》"] +["时有落花至,远随流水香。","刘昚虚《阙题》"] +["别来沧海事,语罢暮天钟。","李益《喜见外弟又言别》"] +["事去千年犹恨速,愁来一日即为长。","李益《同崔邠登鹳雀楼》"] +["汉家青史上,计拙是和亲。","戎昱《咏史 / 和蕃》"] +["江作青罗带,山如碧玉篸。","韩愈《送桂州严大夫同用南字》"] +["知汝远来应有意,好收吾骨瘴江边。","韩愈《左迁至蓝关示侄孙湘》"] +["紫陌红尘拂面来,无人不道看花回。","刘禹锡《元和十年自朗州至京戏赠看花诸君子 / 玄都观桃花》"] +["请君莫奏前朝曲,听唱新翻杨柳枝。","刘禹锡《杨柳枝词九首》"] +["山明水净夜来霜,数树深红出浅黄。","刘禹锡《秋词二首》"] +["试上高楼清入骨,岂如春色嗾人狂。","刘禹锡《秋词二首》"] +["莫道桑榆晚,为霞尚满天。","刘禹锡《酬乐天咏老见示》"] +["天地英雄气,千秋尚凛然。","刘禹锡《蜀先主庙》"] +["少年心事当拿云,谁念幽寒坐呜呃。","李贺《致酒行》"] +["当时我醉美人家,美人颜色娇如花。","卢仝《有所思》"] +["今日美人弃我去,青楼珠箔天之涯。","卢仝《有所思》"] +["钟陵醉别十余春,重见云英掌上身。","罗隐《赠妓云英 / 嘲钟陵妓云英》"] +["此中偏重客,君去必经年。","杜荀鹤《送友游吴越》"] +["香中别有韵,清极不知寒。","崔道融《梅花》"] +["少年辛苦终身事,莫向光阴惰寸功。","杜荀鹤《题弟侄书堂》"] +["北畔是山南畔海,只堪图画不堪行。","杜荀鹤《闽中秋思》"] +["十年身事各如萍,白首相逢泪满缨。","韦庄《与东吴生相遇》"] +["一尺深红胜曲尘,天生旧物不如新。","温庭筠《南歌子词二首 / 新添声杨柳枝词》"] +["宓妃愁坐芝田馆,用尽陈王八斗才。","李商隐《可叹》"] +["乘兴南游不戒严,九重谁省谏书函。","李商隐《隋宫》"] +["高阁客竟去,小园花乱飞。","李商隐《落花》"] +["烦君最相警,我亦举家清。","李商隐《蝉》"] +["向晚意不适,驱车登古原。","李商隐《乐游原 / 登乐游原》"] +["独行潭底影,数息树边身。","贾岛《送无可上人》"] +["曾是寂寥金烬暗,断无消息石榴红。","李商隐《无题·凤尾香罗薄几重》"] +["斑骓只系垂杨岸,何处西南任好风。","李商隐《无题·凤尾香罗薄几重》"] +["卖炭翁,伐薪烧炭南山中。","白居易《卖炭翁》"] +["云鬓花颜金步摇,芙蓉帐暖度春宵。","白居易《长恨歌》"] +["残灯无焰影幢幢,此夕闻君谪九江。","元稹《闻乐天授江州司马》"] +["长安回望绣成堆,山顶千门次第开。","杜牧《过华清宫绝句三首》"] +["潇洒江湖十过秋,酒杯无日不迟留。","杜牧《自宣城赴官上京》"] +["古往今来只如此,牛山何必独霑衣。","杜牧《九日齐山登高 / 九日齐安登高》"] +["此情可待成追忆,只是当时已惘然。","李商隐《锦瑟》"] +["因过竹院逢僧话,又得浮生半日闲。","李涉《题鹤林寺僧舍》"] +["同心一人去,坐觉长安空。","白居易《别元九后咏所怀》"] +["斜拔玉钗灯影畔,剔开红焰救飞蛾。","张祜《赠内人》"] +["妾弄青梅凭短墙,君骑白马傍垂杨。","白居易《井底引银瓶·止淫奔也》"] +["更待菊黄家酝熟,共君一醉一陶然。","白居易《与梦得沽酒闲饮且约后期》"] +["泻水置平地,各自东西南北流。","鲍照《拟行路难十八首》"] +["相悲各罢酒,何时同促膝?","何逊《临行与故游夜别》"] +["大江流日夜,客心悲未央。","谢朓《暂使下都夜发新林至京邑赠西府同僚》"] +["有花堪折直须折,莫待无花空折枝。","无名氏《杂诗》"] +["偶来松树下,高枕石头眠。","太上隐者《答人》"] +["自能窥宋玉,何必恨王昌?","鱼玄机《赠邻女 / 寄李亿员外》"] +["来如春梦几多时?去似朝云无觅处。","白居易《花非花》"] +["何处是归程?长亭连短亭。","李白《菩萨蛮·平林漠漠烟如织》"] +["箫声咽,秦娥梦断秦楼月。","李白《忆秦娥·箫声咽》"] +["千万恨,恨极在天涯。","温庭筠《梦江南·千万恨》"] +["一顾倾人城,再顾倾人国。","李延年《李延年歌》"] +["上言加餐食,下言长相忆。","佚名《饮马长城窟行》"] +["何不策高足,先据要路津。","佚名《今日良宴会》"] +["西北有高楼,上与浮云齐。","佚名《西北有高楼》"] +["上山采蘼芜,下山逢故夫。","佚名《上山采蘼芜》"] +["人面不知何处,绿波依旧东流。","晏殊《清平乐·红笺小字》"] +["无情不似多情苦,一寸还成千万缕。","晏殊《玉楼春·春恨》"] +["往来人,只轮双桨,尽是利名客。","柳永《归朝欢·别岸扁舟三两只》"] +["昨夜因看蜀志,笑曹操孙权刘备。","范仲淹《剔银灯·与欧阳公席上分题》"] +["靓妆眉沁绿,羞脸粉生红。","晏几道《临江仙·斗草阶前初见》"] +["觥船一棹百分空。","晏殊《喜迁莺·花不尽》"] +["蜗角虚名,蝇头微利,算来著甚干忙。","苏轼《满庭芳·蜗角虚名》"] +["长相思,长相思。","晏几道《长相思·长相思》"] +["惊破梦魂无觅处。","苏轼《木兰花令·梧桐叶上三更雨》"] +["羽扇纶巾,谈笑间,樯橹灰飞烟灭。","苏轼《念奴娇·赤壁怀古》"] +["冰肌玉骨,自清凉无汗。","苏轼《洞仙歌·冰肌玉骨》"] +["尊前不用翠眉颦。","苏轼《临江仙·送钱穆父》"] +["今年春尽,杨花似雪,犹不见还家。","苏轼《少年游·润州作》"] +["巨海纳百川,麟阁多才贤。","李白《金门答苏秀才》"] +["庐山东南五老峰,青天削出金芙蓉。","李白《登庐山五老峰》"] +["我醉欲眠卿且去,明朝有意抱琴来。","李白《山中与幽人对酌》"] +["钟期久已没,世上无知音。","李白《月夜听卢子顺弹琴》"] +["一为迁客去长沙,西望长安不见家。","李白《与史郎中钦听黄鹤楼上吹笛 / 题北榭碑》"] +["秋风清,秋月明,落叶聚还散,寒鸦栖复惊。","李白《三五七言 / 秋风词》"] +["身当恩遇常轻敌,力尽关山未解围。","高适《燕歌行》"] +["今夕复何夕,共此灯烛光。","杜甫《赠卫八处士》"] +["翻手作云覆手雨,纷纷轻薄何须数。","杜甫《贫交行》"] +["暮投石壕村,有吏夜捉人。","杜甫《石壕吏》"] +["老妪力虽衰,请从吏夜归。","杜甫《石壕吏》"] +["但见新人笑,那闻旧人哭。","杜甫《佳人》"] +["霍如羿射九日落,矫如群帝骖龙翔。","杜甫《观公孙大娘弟子舞剑器行》"] +["来如雷霆收震怒,罢如江海凝清光。","杜甫《观公孙大娘弟子舞剑器行》"] +["永夜角声悲自语,中天月色好谁看。","杜甫《宿府》"] +["归凤求凰意,寥寥不复闻。","杜甫《琴台》"] +["即从巴峡穿巫峡,便下襄阳向洛阳。","杜甫《闻官军收河南河北》"] +["白头搔更短,浑欲不胜簪。","杜甫《春望》"] +["凉风起天末,君子意如何。","杜甫《天末怀李白》"] +["三顾频烦天下计,两朝开济老臣心。","杜甫《蜀相》"] +["楚塞三湘接,荆门九派通。","王维《汉江临泛 / 汉江临眺》"] +["坐看苍苔色,欲上人衣来。","王维《书事》"] +["昔人已乘黄鹤去,此地空余黄鹤楼。","崔颢《黄鹤楼 / 登黄鹤楼》"] +["城头铁鼓声犹振,匣里金刀血未干。","王昌龄《出塞二首》"] +["来时浦口花迎入,采罢江头月送归。","王昌龄《采莲曲二首》"] +["斜抱云和深见月,朦胧树色隐昭阳。","王昌龄《西宫春怨》"] +["相见情已深,未语可知心。","李白《相逢行二首》"] +["吾爱孟夫子,风流天下闻。","李白《赠孟浩然》"] +["东壁图书府,西园翰墨林。","张说《恩制赐食于丽正殿书院宴赋得林字》"] +["此时相望不相闻,愿逐月华流照君。","张若虚《春江花月夜》"] +["与君离别意,同是宦游人。","王勃《送杜少府之任蜀州 / 送杜少府之任蜀川》"] +["楼台临绝岸,洲渚亘长天。","王勃《重别薛华》"] +["独有宦游人,偏惊物候新。","杜审言《和晋陵陆丞早春游望》"] +["白首相知犹按剑,朱门先达笑弹冠。","王维《酌酒与裴迪》"] +["世事浮云何足问,不如高卧且加餐。","王维《酌酒与裴迪》"] +["泉声咽危石,日色冷青松。","王维《过香积寺》"] +["莫嫌旧日云中守,犹堪一战取功勋。","王维《老将行》"] +["倚杖柴门外,临风听暮蝉。","王维《辋川闲居赠裴秀才迪》"] +["长相思,摧心肝。","李白《长相思三首》"] +["儒生不及游侠人,白首下帷复何益!","李白《行行游且猎篇》"] +["赵客缦胡缨,吴钩霜雪明。","李白《侠客行》"] +["种瓜黄台下,瓜熟子离离。","李贤《黄台瓜辞》"] +["鸦黄粉白车中出,含娇含态情非一。","卢照邻《长安古意》"] +["伐木丁丁,鸟鸣嘤嘤。","佚名《伐木》"] +["长门尽日无梳洗,何必珍珠慰寂寥。","江采萍《谢赐珍珠》"] +["边雪藏行径,林风透卧衣。","贾岛《送邹明府游灵武》"] +["欲往从之雪雰雰,侧身北望涕沾巾。","张衡《四愁诗》"] +["戎衣不脱随霜雪,汗马骖单长被铁。","李昂《从军行》"] +["溪谷少人民,雪落何霏霏!","曹操《苦寒行》"] +["昨夜吴中雪,子猷佳兴发。","李白《答王十二寒夜独酌有怀》"] +["江上雪,独立钓渔翁。","李纲《望江南·江上雪》"] +["积雪覆平皋,饥鹰捉寒兔。","孟浩然《南归阻雪》"] +["乱石穿空,惊涛拍岸,卷起千堆雪。","苏轼《念奴娇·赤壁怀古》"] +["和气吹绿野,梅雨洒芳田。","李世民《咏雨》"] +["苍旻霁凉雨,石路无飞尘。","岑参《暮秋山行》"] +["晓月坠,宿云微,无语枕频欹。","李煜《喜迁莺·晓月坠》"] +["岁寒无与同,朗月何胧胧。","潘安《悼亡诗三首》"] +["早梅发高树,迥映楚天碧。","柳宗元《早梅》"] +["今年欢笑复明年,秋月春风等闲度。","白居易《琵琶行 / 琵琶引》"] +["月出于东山之上,徘徊于斗牛之间。","苏轼《前赤壁赋》"] +["秋丛绕舍似陶家,遍绕篱边日渐斜。","元稹《菊花》"] +["岸旁青草长不歇,空中白雪遥旋灭。","岑参《热海行送崔侍御还京》"] +["日照新妆水底明,风飘香袂空中举。","李白《采莲曲》"] +["安得五彩虹,驾天作长桥。","李白《焦山望寥山》"] +["深涧饮渴虹,邃河生秋雷。","惟凤《姑射山诗题曾山人壁》"] +["彭蠡湖天晚,桃花水气春。","白居易《彭蠡湖晚归》"] +["画楼春早,一树桃花笑。","凌廷堪《点绛唇·春眺》"] +["柳叶开银镝,桃花照玉鞍。","骆宾王《送郑少府入辽共赋侠客远从戎》"] +["芳草平沙,斜阳远树,无情桃叶江头渡。","张翥《踏莎行·芳草平沙》"] +["莲渚收香,兰皋浮爽,凉思顿欺班扇。","王月山《齐天乐·夜来疏雨鸣金井》"] +["棹动芙蓉落,船移白鹭飞。","萧纲《采莲曲》"] +["云锁嫩黄烟柳细,风吹红蒂雪梅残。","阎选《八拍蛮·云锁嫩黄烟柳细》"] +["去时梅萼初凝粉。不觉小桃风力损。","欧阳修《玉楼春·去时梅萼初凝粉》"] +["游子春衫已试单,桃花飞尽野梅酸。","吴涛《绝句·游子春衫已试单》"] +["江南腊尽,早梅花开后,分付新春与垂柳。","苏轼《洞仙歌·咏柳》"] +["花意争春,先出岁寒枝。","辛弃疾《江神子·赋梅寄余叔良》"] +["多谢梅花,伴我微吟。","韩疁《高阳台·除夜》"] +["寒梅最堪恨,常作去年花。","李商隐《忆梅》"] +["天涯也有江南信。梅破知春近。","黄庭坚《虞美人·宜州见梅作》"] +["杨柳回塘,鸳鸯别浦。绿萍涨断莲舟路。","贺铸《芳心苦·杨柳回塘》"] +["江头宫殿锁千门,细柳新蒲为谁绿?","杜甫《哀江头》"] +["一溪烟柳万丝垂,无因系得兰舟住。","周紫芝《踏莎行·情似游丝》"] +["杨柳千条送马蹄,北来征雁旧南飞。","纳兰性德《浣溪沙·杨柳千条送马蹄》"] +["赤阑桥尽香街直,笼街细柳娇无力。","陈克《菩萨蛮·赤阑桥尽香街直》"] +["东风吹我过湖船。杨柳丝丝拂面。","张孝祥《西江月·问讯湖边春色》"] +["西园有分,断柳凄花,似曾相识。","吴文英《瑞鹤仙·秋感》"] +["金柳摇风树树,系彩舫龙舟遥岸。","柳永《破阵乐·露花倒影》"] +["天涯倚楼新恨,杨柳几丝碧。","晏几道《六么令·雪残风信》"] +["雪云散尽,放晓晴池院。杨柳于人便青眼。","李元膺《洞仙歌·雪云散尽》"] +["出犯繁花露,归穿弱柳风。","温庭筠《清明日》"] +["残柳宫前空露叶,夕阳川上浩烟波。","刘沧《经炀帝行宫》"] +["绿池落尽红蕖却,荷叶犹开最小钱。","杨万里《秋凉晚步》"] +["叶润林塘密,衣干枕席清。","杜甫《水槛遣心二首》"] +["槐叶初匀日气凉,葱葱鼠耳翠成双。","范成大《夏日田园杂兴》"] +["杨柳散和风,青山澹吾虑。","韦应物《东郊》"] +["西宫南内多秋草,落叶满阶红不扫。","白居易《长恨歌》"] +["轻肌弱骨散幽葩,更将金蕊泛流霞。","苏轼《赵昌寒菊》"] +["遥怜故园菊,应傍战场开。","岑参《行军九日思长安故园》"] +["春风依旧。著意随堤柳。","赵令畤《清平乐·春风依旧》"] +["数枝门柳低衣桁,一片山花落笔床。","岑参《山房春事二首》"] +["燕子飞时,绿水人家绕。枝上柳绵吹又少。","苏轼《蝶恋花·春景》"] +["杨柳千条拂面丝,绿烟金穗不胜吹。","温庭筠《题柳》"] +["中庭月色正清明,无数杨花过无影。","张先《木兰花·乙卯吴兴寒食》"] +["丝长鱼误恐,枝弱禽惊践。","崔护《五月水边柳》"] +["嫁得瞿塘贾,朝朝误妾期。","李益《江南曲》"] +["老翁逾墙走,老妇出门看。","杜甫《石壕吏》"] +["晓月过残垒,繁星宿故关。","司空曙《贼平后送人北归》"] +["逢流星兮问路,顾我指兮从左。","王逸《九思》"] +["曲终漏尽严具陈,月没星稀天下旦。","佚名《鸡鸣歌》"] +["星汉灿烂,若出其里。","曹操《观沧海 / 碣石篇》"] +["雄州雾列,俊采星驰。","王勃《滕王阁序》"] +["种竹淇园远致君,生平孤节负辛勤。","王汝舟《咏归堂隐鳞洞》"] +["性孤高似柏,阿娇金屋。","陆容《满江红·咏竹》"] +["蜡炬风摇帘不下,竹影半墙如画。","项鸿祚《清平乐·池上纳凉》"] +["兰帐玉人睡觉,怪春衣、雪沾琼缀。","章楶《水龙吟·燕忙莺懒芳残》"] +["小男供饵妇搓丝,溢榼香醪倒接罹。","李郢《南池》"] +["桑柘外秋千女儿,髻双鸦斜插花枝。","卢挚《蟾宫曲·寒食新野道中》"] +["山僧过岭看茶老,村女当垆煮酒香。","祝允明《首夏山中行吟》"] +["小姑织白纻,未解将人语。","李白《湖边采莲妇》"] +["魏都接燕赵,美女夸芙蓉。","李白《魏郡别苏明府因北游》"] +["采莲时,小娘红粉对寒浪。","温庭筠《荷叶杯·镜水夜来秋月》"] +["应倾谢女珠玑箧,尽写檀郎锦绣篇。","罗隐《七夕》"] +["西施越溪女,出自苎萝山。","李白《西施 / 咏苎萝山》"] +["虽然不如延年妹,亦是当时绝世人。","李白《中山孺子妾歌》"] +["新样靓妆,艳溢香融,羞杀蕊珠宫女。","赵佶《燕山亭·北行见杏花》"] +["美人梳洗时,满头间珠翠。","郑遨《富贵曲》"] +["路入南中,桄榔叶暗蓼花红。","欧阳炯《南乡子·路入南中》"] +["秋声带叶萧萧落,莫响城头角!","董士锡《虞美人·韶华争肯偎人住》"] +["败叶填溪水已冰,夕阳犹照短长亭。","纳兰性德《浣溪沙·败叶填溪水已冰》"] +["东门之杨,其叶牂牂。","佚名《东门之杨》"] +["酒市渐闲灯火,正敲窗、乱叶舞纷纷。","孔夷《南浦·旅怀》"] +["青蒲衔紫茸,长叶复从风。","佚名《拔蒲二首》"] +["露叶翻风惊鹊坠。暗落青林红子。","陈师道《清平乐·秋光烛地》"] +["落叶满空山,何处寻行迹。","韦应物《寄全椒山中道士》"] +["水满田畴稻叶齐,日光穿树晓烟低。","徐玑《新凉》"] +["明朝挂帆席,枫叶落纷纷。","李白《夜泊牛渚怀古》"] +["黄叶仍风雨,青楼自管弦。","李商隐《风雨》"] +["子规啼彻四更时,起视蚕稠怕叶稀。","谢枋得《蚕妇吟》"] +["岭水争分路转迷,桄榔椰叶暗蛮溪。","李德裕《谪岭南道中作》"] +["惨惨时节尽,兰叶复凋零。","徐干《室思》"] +["重阳过后,西风渐紧,庭树叶纷纷。","晏殊《少年游·重阳过后》"] +["长条短叶翠濛濛,才过西风,又过东风。","夏完淳《一剪梅·咏柳》"] +["拟泛烟中片叶,但两湖佳处,任风吹泊。","徐灿《念奴娇·西湖雨感次素庵韵》"] +["棘枝风哭酸,桐叶霜颜高。","孟郊《秋怀十五首》"] +["荒戍落黄叶,浩然离故关。","温庭筠《送人东游》"] +["绿叶渐成阴,下有游人归路。","王安石《伤春怨·雨打江南树》"] +["疏林红叶,芙蓉将谢,天然妆点秋屏列。","薛昂夫《西湖杂咏·秋》"] +["叶落根偏固,心虚节更高。","憨山德清《咏竹五首》"] +["绿竹半含箨,新梢才出墙。","杜甫《严郑公宅同咏竹》"] +["归来三径重扫,松竹本吾家。","叶梦得《水调歌头·秋色渐将晚》"] +["野店垂杨步,荒祠苦竹丛。","范成大《寒食郊行书事》"] +["细读离骚还痛饮,饱看修竹何妨肉。","辛弃疾《满江红·山居即事》"] +["榴叶拥花当北户,竹根抽笋出东墙。","汤允绩《浣溪沙·燕垒雏空日正长》"] +["独写菖蒲竹叶杯,蓬城芳草踏初回。","汤显祖《午日处州禁竞渡》"] +["凭栏久,黄芦苦竹,拟泛九江船。","周邦彦《满庭芳·夏日溧水无想山作》"] +["欲吊沈累无所,但有渔儿樵子,哀此写离忧。","张孝祥《水调歌头·过岳阳楼作》"] +["狎浪儿童,横江士女,笑指渔翁一叶轻。","吴伟业《沁园春·观潮》"] +["先生醉也,童子扶者。","曹德《三棒鼓声频·题渊明醉归图》"] +["兵戈既未息,儿童尽东征。","杜甫《羌村三首·其三》"] +["竹马踉蹡冲淖去,纸鸢跋扈挟风鸣。","陆游《观村童戏溪上》"] +["野老念牧童,倚杖候荆扉。","王维《渭川田家》"] +["弄儿床前戏,看妇机中织。","鲍照《拟行路难·其六》"] +["三冬暂就儒生学,千耦还从父老耕。","陆游《观村童戏溪上》"] +["儿童漫相忆,行路岂知难。","元好问《倪庄中秋》"] +["母别子,子别母,白日无光哭声苦。","白居易《母别子》"] +["老母与子别,呼天野草间。","李白《豫章行》"] +["明朝甑复空,母子相持哭。","陆游《十月二十八日风雨大作》"] +["欢宴良宵好月,佳人修竹清风。","吴文英《风入松·麓翁园堂宴客》"] +["春朝物候妍,愁妇镜台前。","郑愔《春怨》"] +["秦女含颦向烟月,愁红带露空迢迢。","温庭筠《惜春词》"] +["玉手佳人,笑把琶琶理。","顾德辉《蝶恋花·春江暖涨桃花水》"] +["武公不但歌淇澳,贤女犹能唁卫侯。","姜塘《摘星楼九日登临》"] +["歌舞尊前,繁华镜里,暗换青青发。","萨都剌《念奴娇·登石头城次东坡韵》"] +["柳径无人,堕絮飞无影。","张先《翦牡丹(舟中闻双琵琶·般涉调)》"] +["因依老宿发心初,半学修心半读书。","王建《寄旧山僧》"] +["天相汉,民怀国。","史达祖《满江红·九月二十一日出京怀古》"] +["运往无淹物,年逝觉已催。","谢灵运《岁暮》"] +["岁月匆匆留不住,鬓已星星堪镊。","吴渊《念奴娇·我来牛渚》"] +["去年人在凤凰池,银烛夜弹丝。","萨都剌《小阑干·去年人在凤凰池》"] +["记得武陵相见日,六年往事堪惊。","周紫芝《临江仙·送光州曾使君》"] +["回日楼台非甲帐,去时冠剑是丁年。","温庭筠《苏武庙》"] +["游人记得承平事,暗喜风光似昔年。","韦庄《长安清明》"] +["献赋十年犹未遇,羞将白发对华簪。","钱起《赠阙下裴舍人》"] +["鬓丝日日添白头,榴锦年年照眼明;","殷尧藩《端午日》"] +["白日何短短,百年苦易满。","李白《短歌行》"] +["昔年多病厌芳尊,今日芳尊惟恐浅。","钱惟演《木兰花·城上风光莺语乱》"] +["闻道黄龙戍,频年不解兵。","沈佺期《杂诗三首·其三》"] +["年年社日停针线。怎忍见、双飞燕。","黄公绍《青玉案·年年社日停针线》"] +["归云一去无踪迹,何处是前期?","柳永《少年游·长安古道马迟迟》"] +["一襟余恨宫魂断,年年翠阴庭树。","王沂孙《齐天乐·蝉》"] +["试问春归谁得见?飞燕,来时相遇夕阳中。","辛弃疾《定风波·暮春漫兴》"] +["耕夫召募爱楼船,春草青青万项田;","张继《闾门即事》"] +["胡蝶梦中家万里,子规枝上月三更。","崔涂《春夕》"] +["街南绿树春饶絮。雪满游春路。","晏几道《御街行·街南绿树春饶絮》"] +["鞭影落春堤,绿锦鄣泥卷。","纳兰性德《生查子·鞭影落春堤》"] +["春日迟迟春草绿,野棠开尽飘香玉。","李洞《绣岭宫词》"] +["春风只在园西畔。荠菜花繁胡蝶乱。","严仁《玉楼春·春思》"] +["乱点碎红山杏发,平铺新绿水蘋生。","白居易《南湖早春》"] +["白马金羁辽海东,罗帷绣被卧春风。","李白《春怨》"] +["愿春暂留,春归如过翼。","周邦彦《六丑·落花》"] +["长夏村墟风日清,檐牙燕雀已生成。","张耒《夏日三首·其一》"] +["湖南为客动经春,燕子衔泥两度新。","杜甫《燕子来舟中作》"] +["碧毯线头抽早稻,青罗裙带展新蒲。","白居易《春题湖上》"] +["残日东风,不放岁华去。","吴文英《祝英台近·除夜立春》"] +["城上春云覆苑墙,江亭晚色静年芳。","杜甫《曲江对雨》"] +["楼上几日春寒,帘垂四面,玉阑干慵倚。","李清照《念奴娇·春情》"] +["柳暝河桥,莺晴台苑,短策频惹春香。","吴文英《夜合花·自鹤江入京泊葑门外有感》"] +["村北村南,谷雨才耕遍。秀麦连冈桑叶贱。","范成大《蝶恋花·春涨一篙添水面》"] +["绿净春深好染衣。际柴扉。","贺铸《钓船归·绿净春深好染衣》"] +["忽然一夜清香发,散作乾坤万里春。","王冕《白梅》"] +["西湖春色归,春水绿於染。","欧阳修《春日西湖寄谢法曹韵》"] +["淡荡春光寒食天。玉炉沈水袅残烟。","李清照《浣溪沙·淡荡春光寒食天》"] +["红酥手,黄縢酒,满城春色宫墙柳。","陆游《钗头凤·红酥手》"] +["少妇今春意,良人昨夜情。","沈佺期《杂诗三首·其三》"] +["二月黄莺飞上林,春城紫禁晓阴阴。","钱起《赠阙下裴舍人》"] +["酒未开樽句未裁,寻春问腊到蓬莱。","曹雪芹《访妙玉乞红梅》"] +["楼外垂杨千万缕。欲系青春,少住春还去。","朱淑真《蝶恋花·送春》"] +["一春不识西湖面。翠羞红倦。","许棐《后庭花·一春不识西湖面》"] +["萋萋春草秋绿,落落长松夏寒。","王维《田园乐七首·其四》"] +["旧苑荒台杨柳新,菱歌清唱不胜春。","李白《苏台览古》"] +["应知早飘落,故逐上春来。","何逊《咏早梅 / 扬州法曹梅花盛开》"] +["东风洒雨露,会入天地春。","李白《送郄昂谪巴中》"] +["秀樾横塘十里香,水花晚色静年芳。","蔡松年《鹧鸪天·赏荷》"] +["纱厨藤簟,玉人罗扇轻缣。","白朴《天净沙·夏》"] +["荷芰风轻帘幕香,绣衣鸂鶒泳回塘,小屏闲掩旧潇湘。","顾敻《浣溪沙·荷芰风轻帘幕香》"] +["泰山嵯峨夏云在,疑是白波涨东海。","李白《早秋单父南楼酬窦公衡》"] +["蛾眉淡了教谁画?瘦岩岩羞戴石榴花。","关汉卿《大德歌·夏》"] +["鸳鸯密语同倾盖,且莫与、浣纱人说。","张炎《疏影·咏荷叶》"] +["藕花珠缀,犹似汗凝妆。","阎选《临江仙·雨停荷芰逗浓香》"] +["酷暑天,葵榴发,喷鼻香十里荷花。","白朴《得胜乐·夏》"] +["胭脂洗出秋阶影,冰雪招来露砌魂。","曹雪芹《薛宝钗咏白海棠》"] +["调角断清秋,征人倚戍楼。","张乔《书边事》"] +["空园白露滴,孤壁野僧邻。","马戴《灞上秋居》"] +["野水参差落涨痕,疏林欹倒出霜根。","苏轼《书李世南所画秋景》"] +["白狼河北音书断,丹凤城南秋夜长。","沈佺期《古意呈补阙乔知之 / 古意 / 独不见》"] +["丹枫万叶碧云边,黄花千点幽岩下。","张抡《踏莎行·秋入云山》"] +["绿蓑江上秋闻笛,红袖楼头夜倚栏。","曹雪芹《香菱咏月·其三》"] +["金河秋半虏弦开,云外惊飞四散哀。","杜牧《早雁》"] +["吹到一片秋香,清辉了如雪。","纳兰性德《琵琶仙·中秋》"] +["十二峰头秋草荒,冷烟寒月过瞿塘。","何景明《竹枝词·十二峰头秋草荒》"] +["冷红叶叶下塘秋。长与行云共一舟。","姜夔《忆王孙·番阳彭氏小楼作》"] +["江皋楼观前朝寺,秋色入秦淮。","徐再思《人月圆·甘露怀古》"] +["何当载酒来,共醉重阳节。","孟浩然《秋登兰山寄张五》"] +["如何肯到清秋日,已带斜阳又带蝉。","李商隐《柳》"] +["醉里秋波,梦中朝雨,都是醒时烦恼。","时彦《青门饮·寄宠人》"] +["城边有古树,日夕连秋声。","李白《沙丘城下寄杜甫》"] +["鄂王坟上草离离,秋日荒凉石兽危。","赵孟頫《岳鄂王墓》"] +["北郭清溪一带流,红桥风物眼中秋,绿杨城郭是扬州。","王士祯《浣溪沙·红桥》"] +["汉口夕阳斜渡鸟,洞庭秋水远连天。","刘长卿《自夏口至鹦鹉洲夕望岳阳寄源中丞》"] +["诗成自写红叶,和恨寄东流。","晏几道《诉衷情·凭觞静忆去年秋》"] +["无限枝头好颜色,可怜开不为重阳。","丘浚《咏菊》"] +["蒂有余香金淡泊,枝无全叶翠离披。","曹雪芹《残菊》"] +["官河水静阑干暖,徙倚斜阳怨晚秋。","史达祖《鹧鸪天·卫县道中有怀其人》"] +["辞君向天姥,拂石卧秋霜。","李白《别储邕之剡中》"] +["秋霜不惜人,倏忽侵蒲柳。","李白《长歌行》"] +["一点烽传散关信,两行雁带杜陵秋。","陆游《秋晚登城北楼》"] +["贾傅松醪酒,秋来美更香。","杜牧《送薛种游湖南》"] +["远村秋色如画,红树间疏黄。","晏殊《诉衷情·芙蓉金菊斗馨香》"] +["凭画槛,雨洗秋浓人淡。","厉鹗《谒金门·七月既望湖上雨后作》"] +["问篱边黄菊,知为谁开。","秦观《满庭芳·碧水惊秋》"] +["重九开秋节,得一动宸仪。","韦安石《奉和九日幸临渭亭登高应制得枝字》"] +["凉叶萧萧散雨声,虚堂淅淅掩霜清。","元好问《秋怀》"] +["幽音变调忽飘洒,长风吹林雨堕瓦。","李颀《听董大弹胡笳声兼寄语弄房给事》"] +["苔深不能扫,落叶秋风早。","李白《长干行二首》"] +["风递幽香出,禽窥素艳来。","齐己《早梅》"] +["江水侵云影,鸿雁欲南飞。","朱熹《水调歌头·隐括杜牧之齐山诗》"] +["明月黄河夜,寒沙似战场。","李流芳《黄河夜泊》"] +["西湖到日,重见梅钿皱。","吴文英《瑞龙吟·送梅津》"] +["记取西湖西畔,正暮山好处,空翠烟霏。","苏轼《八声甘州·寄参寥子》"] +["鱼泽滩头嘶猎马,省嵬城畔看黄河。","石茂华《九月九日登长城关》"] +["将军发白马,旌节度黄河。","李白《发白马》"] +["莫向西湖歌此曲,水光山色不胜悲。","赵孟頫《岳鄂王墓》"] +["何人解赏西湖好,佳景无时。","欧阳修《采桑子·何人解赏西湖好》"] +["枕中云气千峰近,床底松声万壑哀。","曾公亮《宿甘露寺僧舍》"] +["不知香积寺,数里入云峰。","王维《过香积寺》"] +["林壑敛暝色,云霞收夕霏。","谢灵运《石壁精舍还湖中作》"] +["云开远见汉阳城,犹是孤帆一日程。","卢纶《晚次鄂州》"] +["行宫见月伤心色,夜雨闻铃肠断声。","白居易《长恨歌》"] +["舞殿冷袖,风雨凄凄。","杜牧《阿房宫赋》"] +["垂下帘栊。双燕归来细雨中。","欧阳修《采桑子·群芳过后西湖好》"] +["波漂菰米沉云黑,露冷莲房坠粉红。","杜甫《秋兴八首》"] +["又疑瑶台镜,飞在青云端。","李白《古朗月行》"] +["绿云扰扰,梳晓鬟也;","杜牧《阿房宫赋》"] +["骊宫高处入青云,仙乐风飘处处闻。","白居易《长恨歌》"] +["丹阳城南秋海阴,丹阳城北楚云深。","王昌龄《芙蓉楼送辛渐二首》"] +["卷峭寒万里,平沙飞雪。","吴文英《暗香疏影·夹钟宫赋墨梅》"] +["去年射虎南山秋,夜归急雪满貂裘。","陆游《三月十七日夜醉中作》"] +["正西望长安,下见江水流。","李白《秋浦歌十七首》"] +["江上晚来堪画处,钓鱼人一蓑归去。","马致远《寿阳曲·江天暮雪》"] +["应是夜寒凝。恼得梅花睡不成。","黄升《南乡子·冬夜》"] +["正是霜风飘断处,寒鸥惊起一双双。","陆龟蒙《冬柳》"] +["野桥梅几树,并是白纷纷。","洪升《雪望》"] +["冻脸有痕皆是血,酸心无恨亦成灰。","曹雪芹《咏红梅花得“梅”字》"] +["朔风吹雪透刀瘢,饮马长城窟更寒。","卢汝弼《和李秀才边庭四时怨·其四》"] +["雪粉华,舞梨花,再不见烟村四五家。","关汉卿《大德歌·冬景》"] +["漠漠黄云,湿透木棉裘。","蒋捷《梅花引·荆溪阻雪》"] +["横槊题诗,登楼作赋,万事空中雪。","文天祥《酹江月·和友驿中言别》"] +["百花头上开,冰雪寒中见。","辛弃疾《生查子·重叶梅》"] +["叩枻长歌,嫦娥欲下,万里挥冰雪。","赵秉文《大江东去·用东坡先生韵》"] +["正是天山雪下时,送君走马归京师。","岑参《天山雪歌送萧治归京》"] +["残雪庭阴,轻寒帘影,霏霏玉管春葭。","王沂孙《高阳台·和周草窗寄越中诸友韵》"] +["山雪河冰野萧瑟,青是烽烟白人骨。","杜甫《悲青坂》"] +["孤烟村际起,归雁天边去。","孟浩然《南归阻雪》"] +["想雁空、北落冬深,澹墨晚天云阔。","吴文英《暗香疏影·夹钟宫赋墨梅》"] +["寒沙四面平,飞雪千里惊。","范云《效古诗》"] +["秋萧索。梧桐落尽西风恶。","黄机《忆秦娥·秋萧索》"] +["今日云景好,水绿秋山明。","李白《九日》"] +["渌水明秋月,南湖采白蘋。","李白《渌水曲》"] +["晴野鹭鸶飞一只,水葓花发秋江碧。","皇甫松《天仙子·踯躅花开红照水》"] +["地白风色寒,雪花大如手。","李白《嘲王历阳不肯饮酒》"] +["雪似梅花,梅花似雪。似和不似都奇绝。","吕本中《踏莎行·雪似梅花》"] +["八年十二月,五日雪纷纷。","白居易《村居苦寒》"] +["怒涛卷霜雪,天堑无涯。","柳永《望海潮·东南形胜》"] +["雪照山城玉指寒,一声羌管怨楼间。","刘著《鹧鸪天·雪照山城玉指寒》"] +["雪洗虏尘静,风约楚云留。","张孝祥《水调歌头·和庞佑父》"] +["衔霜当路发,映雪拟寒开。","何逊《咏早梅 / 扬州法曹梅花盛开》"] +["火冷灯稀霜露下,昏昏雪意云垂野。","苏轼《蝶恋花·密州上元》"] +["散关三尺雪,回梦旧鸳机。","李商隐《悼伤后赴东蜀辟至散关遇雪》"] +["雪里温柔,水边明秀,不借春工力。","辛弃疾《念奴娇·梅》"] +["山边幽谷水边村,曾被疏花断客魂。","张嵲《墨梅》"] +["无限河山泪,谁言天地宽!","夏完淳《别云间》"] +["隋堤三月水溶溶。背归鸿,去吴中。","苏轼《江神子·恨别》"] +["太乙近天都,连山接海隅。","王维《终南山》"] +["淡荡春光寒食天。玉炉沈水袅残烟。梦回山枕隐花钿。","李清照《浣溪沙·淡荡春光寒食天》"] +["吴丝蜀桐张高秋,空山凝云颓不流。","李贺《李凭箜篌引》"] +["楼头客子杪秋后,日落君山元气中。","陈与义《登岳阳楼》"] +["河水萦带,群山纠纷。","李华《吊古战场文》"] +["江水漾西风,江花脱晚红。","王安石《江上》"] +["风休住。蓬舟吹取三山去。","李清照《渔家傲·天接云涛连晓雾》"] +["我家襄水曲,遥隔楚云端。","孟浩然《早寒江上有怀 / 早寒有怀 / 江上思归》"] +["白毛浮绿水,红掌拨清波。","骆宾王《咏鹅》"] +["占得杏梁安稳处,体轻唯有主人怜,堪羡好因缘。","牛峤《忆江南·衔泥燕》"] +["曾把芳心深相许。故梦劳诗苦。","史达祖《留春令·咏梅花》"] +["君歌杨叛儿,妾劝新丰酒。","李白《杨叛儿》"] +["应难奈,故人天际,望彻淮山,相思无雁足。","史达祖《八归·秋江带雨》"] +["风摧寒棕响,月入霜闺悲。","李白《独不见》"] +["细腰争舞君沉醉,白日秦兵天下来。","李涉《楚宫怨二首·其一》"] +["长江巨浪征人泪,一夜西风共白头。","宋琬《江上阻风》"] +["料黛眉重锁隋堤,芳心还动梁苑。","卢祖皋《宴清都·初春》"] +["尘缘一点,回首西风又陈迹。","吴文英《六么令·夷则宫七夕》"] +["还始觉、留情缘眼,宽带因春。","吴文英《渡江云三犯·西湖清明》"] +["但怪得、当年梦缘能短。","吴文英《三姝媚·过都城旧居有感》"] +["君知否,是山西将种,曾系诗盟。","刘过《沁园春·张路分秋阅》"] +["算遗踪、犹有枕囊留,相思物。","吴文英《满江红·刘朔斋赋菊和韵》"] +["盈盈一点芳心,占多少春光,问卿知否?红妆莫斗。","顾太清《玉烛新·白海棠》"] +["春朝秋夜思君甚,愁见绣屏孤枕。","魏承班《满宫花·雪霏霏》"] +["自与东君作别,刬地无聊。","纳兰性德《风流子·秋郊即事》"] +["写不了相思,又蘸凉波飞去。","朱彝尊《长亭怨慢·雁》"] +["辜负我,悔怜君,告天天不闻。","牛峤《更漏子·春夜阑》"] +["绮席凝尘,香闺掩雾。","晏殊《踏莎行·碧海无波》"] +["问别来、解相思否。","吴文英《惜秋华·七夕前一日送人归盐官》"] +["思归未可得,书此谢情人。","李白《送郄昂谪巴中》"] +["情如之何,暮涂为客,忍堪送君。","吴文英《沁园春·送翁宾旸游鄂渚》"] +["画船捶鼓催君去。高楼把酒留君住。","舒亶《菩萨蛮·画船捶鼓催君去》"] +["群燕辞归鹄南翔,念君客游多断肠。","曹丕《燕歌行》"] +["红烛背,绣帘垂,梦长君不知。","温庭筠《更漏子·柳丝长》"] +["此曲有意无人传,愿随春风寄燕然,忆君迢迢隔青天。","李白《长相思三首·其二》"] +["十二楼中尽晓妆,望仙楼上望君王。","薛逢《宫词》"] +["忆君迢迢隔青天,昔日横波目,今作流泪泉。","李白《长相思·其二》"] +["新知遭薄俗,旧好隔良缘。","李商隐《风雨》"] +["去年上巳洛桥边,今年寒食庐山曲。","宋之问《寒食江州满塘驿》"] +["昼夜蔽日月,冬夏共霜雪。","谢灵运《登庐山绝顶望诸峤》"] +["东风吹雨过青山,却望千门草色闲。","卢纶《长安春望》"] +["高山安可仰,徒此揖清芬。","李白《赠孟浩然》"] +["江帆几片疾如箭,山泉千尺飞如电。","周德清《塞鸿秋·浔阳即景》"] +["不成抛掷,梦里终相觅。","王国维《点绛唇·屏却相思》"] +["吴洲春草兰杜芳,感物思归怀故乡。","宋之问《寒食江州满塘驿》"] +["醉后莫思家,借取师师宿。","晏几道《生查子·落梅庭榭香》"] +["为有书来与我期,便从兰杜惹相思。","庄棫《定风波·为有书来与我期》"] +["春色边城动,客思故乡来。","何逊《边城思》"] +["上有流思人,怀旧望归客。","谢朓《送江水曹还远馆》"] +["临水一长啸,忽思十年初。","白居易《垂钓》"] +["夜寒微透薄罗裳,无限思量。","秦观《画堂春·春情》"] +["傍邻闻者多叹息,远客思乡皆泪垂。","李颀《听安万善吹觱篥歌》"] +["要问相思,天涯犹自短。","晏几道《清商怨·庭花香信尚浅》"] +["相思血,都沁绿筠枝。","姜夔《小重山令·赋潭州红梅》"] +["料有牵情处,忍思量、耳边曾道。","时彦《青门饮·寄宠人》"] +["翘思慕远人,愿欲托遗音。","曹植《杂诗六首·其一》"] +["遥知未眠月,乡思在渔歌。","杜荀鹤《送人游吴》"] +["碧芜千里思悠悠,惟有霎时凉梦、到南州。","李廌《虞美人·玉阑干外清江浦》"] +["思归若汾水,无日不悠悠。","李白《太原早秋》"] +["上有愁思妇,悲叹有余哀。","曹植《明月上高楼》"] +["忽闻歌古调,归思欲沾巾。","杜审言《和晋陵陆丞早春游望》"] +["红叶黄花秋意晚,千里念行客。","晏几道《思远人·红叶黄花秋意晚》"] +["故园渺何处,归思方悠哉。","韦应物《闻雁》"] +["伤心阔别三千里,屈指思量四五年。","韩偓《寒食日重游李氏园亭有怀》"] +["说相思,问相思,枫落吴江雁去迟。","吴锡麒《长相思·以书寄西泠诸友即题其后》"] +["相思只在梦魂中。今宵月,偏照小楼东。","何大圭《小重山·绿树莺啼春正浓》"] +["已知泉路近,欲别故乡难。","夏完淳《别云间》"] +["相去日已远,衣带日已缓。","佚名《行行重行行》"] +["明朝望乡处,应见陇头梅。","宋之问《题大庾岭北驿》"] +["正见空江明月来,云水苍茫失江路。","欧阳修《晚泊岳阳》"] +["异乡物态与人殊,惟有东风旧相识。","欧阳修《春日西湖寄谢法曹韵》"] +["行行无别语,只道早还乡。","袁凯《京师得家书》"] +["试问乡关何处是,水云浩荡迷南北。","赵鼎《满江红·丁未九月南渡泊舟仪真江口作》"] +["四海十年兵不解,胡尘直到江城。","陈克《临江仙·四海十年兵不解》"] +["威加海内兮归故乡。安得猛士兮守四方!","刘邦《大风歌》"] +["停船暂借问,或恐是同乡。","崔颢《长干行·君家何处住》"] +["青紫虽被体,不如早还乡。","杜甫《夏夜叹》"] +["岁夜高堂列明烛,美酒一杯声一曲。","李颀《听安万善吹觱篥歌》"] +["还将两行泪,遥寄海西头。","孟浩然《宿桐庐江寄广陵旧游》"] +["乡心新岁切,天畔独潸然。","刘长卿《新年作》"] +["田园寥落干戈后,骨肉流离道路中。","白居易《望月有感》"] +["故人溪上,挂愁无奈,烟梢月树。","史达祖《留春令·咏梅花》"] +["故人在其下,尘土相豗蹴。","苏轼《送顿起》"] +["何处相逢,登宝钗楼,访铜雀台。","刘克庄《沁园春·梦孚若》"] +["松江上,念故人老矣,甘卧闲云。","吴文英《沁园春·送翁宾旸游鄂渚》"] +["湖水无端浸白云,故人书断孤鸿没。","黄庭坚《秋怀二首》"] +["日斜江上孤帆影,草绿湖南万里情。","刘长卿《别严士元》"] +["故人一去无期约。尺书忽寄西飞鹤。","孙道绚《忆秦娥·季温老友归樵阳人来闲书因以为寄》"] +["竹边荷外再相逢,又还是、浮云飞去。","赵以夫《鹊桥仙·富沙七夕为友人赋》"] +["故人应念,杜鹃枝上残月。","文天祥《酹江月·和友驿中言别》"] +["伤心故人去后,冷落新诗。","李邴《汉宫春·梅》"] +["相逢成夜宿,陇月向人圆。","杜甫《宿赞公房》"] +["前年伐月支,城上没全师。","张籍《没蕃故人》"] +["感此怀故人,中宵劳梦想。","孟浩然《夏日南亭怀辛大》"] +["十年离乱后,长大一相逢。","李益《喜见外弟又言别》"] +["但见宵从海上来,宁知晓向云间没。","李白《把酒问月·故人贾淳令予问之》"] +["乱离何处见,消息苦难真。","吴伟业《遇旧友》"] +["翠娥执手送临歧,轧轧开朱户。","柳永《采莲令·月华收》"] +["脱鞍暂入酒家垆,送君万里西击胡。","岑参《送李副使赴碛西官军》"] +["东风兮东风,为我吹行云使西来。","李白《久别离》"] +["举头望明月,低头思故乡。","李白《静夜思》"] +["去年离别雁初归,今夜裁缝萤已飞。","张纮《闺怨 / 怨诗》"] +["长因送人处,忆得别家时。","张籍《蓟北旅思 / 送远人》"] +["为近都门多送别,长条折尽减春风。","白居易《青门柳》"] +["风雨送人来,风雨留人住。","游次公《卜算子·风雨送人来》"] +["河桥送人处,凉夜何其。","周邦彦《夜飞鹊·河桥送人处》"] +["分明小像沉香缕,一片伤心欲画难。","纳兰性德《于中好·送梁汾南还为题小影》"] +["手种堂前垂柳,别来几度春风。","欧阳修《朝中措·送刘仲原甫出守维扬》"] +["谢亭离别处,风景每生愁。","李白《谢公亭·盖谢脁范云之所游》"] +["客自长安来,还归长安去。","李白《金乡送韦八之西京》"] +["送君九月交河北,雪里题诗泪满衣。","岑参《送崔子还京》"] +["江上几人在,天涯孤棹还。","温庭筠《送人东游》"] +["漠漠帆来重,冥冥鸟去迟。","韦应物《赋得暮雨送李胄 / 赋得暮雨送李曹》"] +["只应守寂寞,还掩故园扉。","孟浩然《留别王侍御维 / 留别王维》"] +["惆怅孤帆连夜发,送行淡月微云。","苏轼《临江仙·送钱穆父》"] +["新啼痕压旧啼痕,断肠人忆断肠人。","王实甫《十二月过尧民歌·别情》"] +["一曲危弦断客肠。津桥捩柂转牙樯。","严仁《鹧鸪天·惜别》"] +["彩舟载得离愁动。无端更借樵风送。","贺铸《菩萨蛮·彩舟载得离愁动》"] +["何当重相见,尊酒慰离颜。","温庭筠《送人东游》"] +["雁来音信无凭,路遥归梦难成。","李煜《清平乐·别来春半》"] +["白白与红红,别是东风情味。","严蕊《如梦令·道是梨花不是》"] +["徒把金戈挽落晖,南冠无奈北风吹。","虞集《挽文丞相》"] +["林花扫更落,径草踏还生。","孟浩然《春中喜王九相寻 / 晚春》"] +["寒随一夜去,春逐五更来。","史青《应诏赋得除夜》"] +["寒雪梅中尽,春风柳上归。","李白《宫中行乐词八首》"] +["林花著雨胭脂湿,水荇牵风翠带长。","杜甫《曲江对雨》"] +["燕燕飞来,问春何在,唯有池塘自碧。","姜夔《淡黄柳·空城晓角》"] +["后庭花一曲,幽怨不堪听。","刘禹锡《金陵怀古》"] +["遗庙丹青落,空山草木长。","杜甫《武侯庙》"] +["自从明宰投巫后,直至如今鬼不神。","汪遵《西河》"] +["虎踞龙蟠何处是,只有兴亡满目。","辛弃疾《念奴娇·登建康赏心亭呈史致道留守》"] +["隔岸荒云远断,绕矶小树微明。","归庄《锦堂春·半壁横江矗起》"] +["凭空怒浪兼天涌,不尽六朝声。","归庄《锦堂春·半壁横江矗起》"] +["年来肠断秣陵舟,梦绕秦淮水上楼。","王士祯《秦淮杂诗二十首·其一》"] +["歌沉玉树,古寺空有疏钟发。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["怅然孤啸,青山故国,乔木苍苔。","倪瓒《人月圆·伤心莫问前朝事》"] +["千载休谈南渡错,当时自怕中原复。","文征明《满江红·拂拭残碑》"] +["翠华想像空山里,玉殿虚无野寺中。","杜甫《咏怀古迹五首·其四》"] +["楚人悲屈原,千载意未歇。","苏轼《屈原塔》"] +["举目山河异,偏伤周顗情。","李白《金陵新亭》"] +["几度东风吹世换,千年往事随潮去。","戴复古《满江红·赤壁怀古》"] +["不知何日东瀛变,此地还成要路津。","刘禹锡《汉寿城春望》"] +["素臣称有道,守在於四夷。","田锡《塞上曲》"] +["谁分去来乡国事,凄然,曾是承平两少年。","陈洵《南乡子·己巳三月自郡城归乡过区菶吾西园话旧》"] +["同来不得同归去,故国逢春一寂寥!","杜牧《宣州送裴坦判官往舒州时牧欲赴官归京》"] +["为问花何在,夜来风雨,葬楚宫倾国。","周邦彦《六丑·落花》"] +["形胜三分国,波流万世功。","潘大临《江间作四首·其三》"] +["忘身辞凤阙,报国取龙庭。","王维《送赵都督赴代州得青字》"] +["少年射虎名豪,等闲赤羽千夫膳。","元好问《水龙吟·从商帅国器猎于南阳同仲泽鼎玉赋此》"] +["起望衣冠神州路,白日销残战骨。","辛弃疾《贺新郎·用前韵送杜叔高》"] +["平生端有活国计,百不一试薶九京。","黄庭坚《送范德孺知庆州》"] +["万国奔腾,两宫幽陷,此恨何时雪。","黄中辅《念奴娇·炎精中否》"] +["箭径酸风射眼,腻水染花腥。","吴文英《八声甘州·灵岩陪庾幕诸公游》"] +["伤心千里江南,怨曲重招,断魂在否?","吴文英《莺啼序·春晚感怀》"] +["悲怀感物来,泣涕应情陨。","潘安《悼亡诗三首》"] +["寝兴目存形,遗音犹在耳。","潘安《悼亡诗三首》"] +["秋霜欲下手先知,灯底裁缝剪刀冷。","白居易《空闺怨》"] +["春风不相识,何事入罗帏。","李白《春思》"] +["三岁为妇,靡室劳矣。夙兴夜寐,靡有朝矣。","佚名《氓》"] +["香消翠减,雨昏烟暗,芳草遍江南。","乔吉《小桃红·春闺怨》"] +["独上小楼春欲暮,愁望玉关芳草路。","韦庄《木兰花·独上小楼春欲暮》"] +["倚阑凝望,独立渔翁满江雪。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["知君独坐青轩下,此时结念同所怀。","李白《早秋单父南楼酬窦公衡》"] +["独抱影儿眠,背看灯花落。","汪懋麟《误佳期·闺怨》"] +["午枕梦初残,高楼上,独凭阑干。","朱栴《青杏儿·秋》"] +["谁念文园病客?夜色沉沉,独抱一天岑寂。","张埜《夺锦标·七夕》"] +["倚篷窗无寐,引杯孤酌。","张元干《满江红·自豫章阻风吴城山作》"] +["桃今百馀尺,花落成枯枝。","李白《独不见》"] +["为容不在貌,独抱孤洁。","张炎《疏影·梅影》"] +["甚独抱清高,顿成凄楚。","王沂孙《齐天乐·蝉》"] +["绝顶茅庵里,老衲正孤吟。","纳兰性德《水调歌头·题西山秋爽图》"] +["谁怜散髻吹笙,天涯芳草关情。","纳兰性德《清平乐·孤花片叶》"] +["虚牖传寒柝,孤灯照绝编。","欧阳詹《除夜长安客舍》"] +["绕遍回廊还独坐。月笼云暗重门锁。","苏轼《蝶恋花·雨霰疏疏经泼火》"] +["独自立瑶阶,透寒金缕鞋。","纳兰性德《菩萨蛮·隔花才歇廉纤雨》"] +["民感桑林雨,云施李靖龙。","赵汝愚《雨后送李将军还祠偕同寅饮一杯亭》"] +["功名炊黍寻常梦,怪事书空感激人。","王逊《西夏重阳》"] +["顽老情怀,都无欢事,良宵爱幽独。","吴文英《一寸金·秋感》"] +["报国无门空自怨,济时有策从谁吐。","吴潜《满江红·送李御带珙》"] +["诚既勇兮又以武,终刚强兮不可凌。","屈原《国殇》"] +["云鬟绿鬓罢梳结,愁如回飙乱白雪。","李白《久别离》"] +["王母欢阑琼宴罢,仙人泪满金盘侧。","文天祥《满江红·代王夫人作》"] +["中年亲友难别,丝竹缓离愁。","苏轼《水调歌头·安石在东海》"] +["只匆匆眺远,早觉闲愁挂乔木。","史达祖《八归·秋江带雨》"] +["日边消息空沈沈。画眉楼上愁登临。","郑文妻《忆秦娥·花深深》"] +["晚霁江天好,分明愁杀人。","宋之问《始安秋日》"] +["黄花本是无情物,也共先生晚节香。","于谦《过菊江亭》"] +["楚江湄,湘娥乍见,无言洒清泪。","周密《绣鸾凤花犯·赋水仙》"] +["天外凤凰谁得髓?无人解合续弦胶。","杜牧《读韩杜集》"] +["故人何处。带我离愁江外去。","吕本中《减字木兰花·去年今夜》"] +["尽无言、闲品秦筝,泪满参差雁。","吕渭老《薄幸·青楼春晚》"] +["曲径穿花寻蛱蝶,虚阑傍日教鹦鹉。","岳珂《满江红·小院深深》"] +["待到黄昏月上时,依旧柔肠断。","程垓《卜算子·独自上层楼》"] +["小雨空帘,无人深巷,已早杏花先卖。","史达祖《夜行船·正月十八日闻卖杏花有感》"] +["清愁满眼共谁论。却应台下草,不解忆王孙。","李之仪《临江仙·登凌歊台感怀》"] +["日落波平。愁损辞乡去国人。","朱敦儒《采桑子·彭浪矶》"] +["秋江带雨,寒沙萦水,人瞰画阁愁独。","史达祖《八归·秋江带雨》"] +["雾冷笙箫,风轻环佩,玉锁无人掣。","朱敦儒《念奴娇·插天翠柳》"] +["雨下飞花花上泪,吹不去,两难禁。","陈子龙《唐多令·寒食》"] +["机中锦字论长恨,楼上花枝笑独眠。","皇甫冉《春思》"] +["重阳独酌杯中酒,抱病起登江上台。","杜甫《九日五首·其一》"] +["辇毂繁华事可伤,师师重老过湖湘。","刘子翚《汴京纪事》"] +["谁念迁客归来,老大伤名节。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["春无主!杜鹃啼处,泪洒胭脂雨。","陈子龙《点绛唇·春日风雨有感》"] +["想文君望久,倚竹愁生步罗袜。","姜夔《八归·湘中送胡德华》"] +["倚竹不胜愁,暗想江头归路。","王之道《如梦令·一晌凝情无语》"] +["一片闲愁,想丹青难貌。","柳永《尾犯·夜雨滴空阶》"] +["已觉逝川伤别念,复看津树隐离舟。","王勃《秋江送别二首》"] +["谩道愁须殢酒,酒未醒、愁已先回。","秦观《满庭芳·碧水惊秋》"] +["唯有闲愁将不去,依旧住,伴人直到黄昏雨。","段克己《渔家傲·诗句一春浑漫与》"] +["佳期大堤下,泪向南云满。","李白《大堤曲》"] +["寒食时看郭外春,野人无处不伤神。","云表《寒食诗》"] +["客醉倚河桥,清光愁玉箫。","高观国《菩萨蛮·何须急管吹云暝》"] +["春衫著破谁针线。点点行行泪痕满。","黄公绍《青玉案·年年社日停针线》"] +["那堪疏雨滴黄昏。更特地、忆王孙。","欧阳修《少年游·栏干十二独凭春》"] +["道人憔悴春窗底。闷损阑干愁不倚。","李清照《玉楼春·红酥肯放琼苞碎》"] +["伤心莫语,记那日旗亭,水嬉散尽,中酒阻风去。","纳兰性德《摸鱼儿·午日雨眺》"] +["愁损翠黛双蛾,日日画阑独凭。","史达祖《双双燕·咏燕》"] +["几日喜春晴,几夜愁春雨。","高观国《卜算子·泛西湖坐间寅斋同赋》"] +["眉尖早识愁滋味,娇羞未解论心事。","黄公度《菩萨蛮·眉尖早识愁滋味》"] +["粉黛暗愁金带枕,鸳鸯空绕画罗衣,那堪辜负不思归!","顾敻《浣溪沙·云淡风高叶乱飞》"] +["落日无人松径里,鬼火高低明灭。","萨都剌《念奴娇·登石头城次东坡韵》"] +["明日近长安,客心愁未阑。","纳兰性德《菩萨蛮·白日惊飚冬已半》"] +["争奈醒来,愁恨又依然。","朱淑真《江城子·赏春》"] +["花渐凋疏不耐风,画帘垂地晚重工,堕阶萦藓舞愁红。","孙光宪《浣溪沙·花渐凋疏不耐风》"] +["失意还独语,多愁只自知。","张籍《蓟北旅思 / 送远人》"] +["不剪春衫愁意态。过收灯、有些寒在。","史达祖《夜行船·正月十八日闻卖杏花有感》"] +["桐花半亩,静锁一庭愁雨。","周邦彦《琐窗寒·寒食》"] +["短梦依然江表,老泪洒西州。","张炎《八声甘州·记玉关踏雪事清游》"] +["忽然更作渔阳掺,黄云萧条白日暗。","李颀《听安万善吹觱篥歌》"] +["恨萧萧、无情风雨,夜来揉损琼肌。","李清照《多丽·咏白菊》"] +["断肠片片飞红,都无人管,倩谁唤、流莺声住。","辛弃疾《祝英台近·晚春》"] +["情似游丝,人如飞絮。泪珠阁定空相觑。","周紫芝《踏莎行·情似游丝》"] +["坠粉飘香,日日唤愁生。","卢祖皋《江城子·画楼帘暮卷新晴》"] +["天上人间何处去,旧欢新梦觉来时,黄昏微雨画帘垂。","张曙《浣溪沙·枕障薰炉隔绣帷》"] +["燕子归来愁不语。旧巢无觅处。","李好古《谒金门·花过雨》"] +["知君惯度祁连城,岂能愁见轮台月。","岑参《送李副使赴碛西官军》"] +["愁云淡淡雨潇潇。暮暮复朝朝。","石孝友《眼儿媚·愁云淡淡雨萧萧》"] +["独上小搂春欲暮,愁望玉关芳草路。","韦庄《木兰花·独上小楼春欲暮》"] +["断肠何必更残阳,极目伤平楚。","廖世美《烛影摇红·题安陆浮云楼》"] +["江亭暇日堪高会,醉讽离骚不解愁。","边贡《午日观竞渡》"] +["酒醒人静奈愁浓。残灯孤枕梦,轻浪五更风。","徐昌图《临江仙·饮散离亭西去》"] +["催成清泪,惊残孤梦,又拣深枝飞去。","陆游《鹊桥仙·夜闻杜鹃》"] +["万里伤心严谴日,百年垂死中兴时。","杜甫《送郑十八虔贬台州司户伤其临老陷贼之故阙为面别情见于诗》"] +["低头独长叹,此叹无人喻。","白居易《买花 / 牡丹》"] +["梯横画阁黄昏后,又还是、斜月帘栊。","张先《一丛花令·伤高怀远几时穷》"] +["明月,明月,胡笳一声愁绝。","戴叔伦《转应曲·边草》"] +["易得凋零,更多少无情风雨。","赵佶《宴山亭·北行见杏花裁剪冰绡》"] +["桃花洞,瑶台梦,一片春愁谁与共?","和凝《天仙子·柳色披衫金缕凤》"] +["愁里高歌梁父吟,犹如金玉戛商音。","郑思肖《二砺》"] +["欲共柳花低诉,怕柳花轻薄,不解伤春。","黄孝迈《湘春夜月·近清明》"] +["杜诗韩笔愁来读,似倩麻姑痒处搔。","杜牧《读韩杜集》"] +["绿鬓能供多少恨,未肯无情比断弦。","晏几道《破阵子·柳下笙歌庭院》"] +["无限伤心夕照中,故国凄凉,剩粉余红。","夏完淳《一剪梅·咏柳》"] +["握手一长叹,泪为生别滋。","佚名《留别妻》"] +["寂寂闻猿愁,行行见云收。","李白《寻高凤石门山中元丹丘》"] +["凌余阵兮躐余行,左骖殪兮右刃伤。","屈原《国殇》"] +["晚日金陵岸草平,落霞明,水无情。","欧阳炯《江城子·晚日金陵岸草平》"] +["东风有信无人见,露微意、柳际花边。","苏轼《一丛花·今年春浅侵年》"] +["遗民忍死望恢复,几处今宵垂泪痕。","陆游《关山月》"] +["骚人可煞无情思,何事当年不见收。","李清照《鹧鸪天·桂花》"] +["髻子伤春慵更梳。晚风庭院落梅初。","李清照《浣溪沙·髻子伤春慵更梳》"] +["总是愁媒,欲诉谁消遣。","苏轼《蝶恋花·蝶懒莺慵春过半》"] +["独抱浓愁无好梦,夜阑犹剪灯花弄。","李清照《蝶恋花·暖雨晴风初破冻》"] +["当关不报侵晨客,新得佳人字莫愁。","李商隐《富平少侯》"] +["绿杨芳草几时休,泪眼愁肠先已断。","钱惟演《木兰花·城上风光莺语乱》"] +["曲港跳鱼,圆荷泻露,寂寞无人见。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["午梦初回,卷帘尽放春愁去。","周晋《点绛唇·访牟存叟南漪钓隐》"] +["才过清明,渐觉伤春暮。","李冠《蝶恋花·春暮》"] +["远信入门先有泪,妻惊女哭问何如。","元稹《得乐天书》"] +["谪仙何处,无人伴我白螺杯。","黄庭坚《水调歌头·游览》"] +["山暝闻猿愁,沧江急夜流。","孟浩然《宿桐庐江寄广陵旧游》"] +["烽火平安夜,归梦到家山。","崔与之《水调歌头·题剑阁》"] +["百结愁肠郁不开,此生惆怅异乡来。","秋瑾《九日感赋》"] +["马上逢寒食,愁中属暮春。","宋之问《途中寒食题黄梅临江驿寄崔融》"] +["耕犁千亩实千箱,力尽筋疲谁复伤?","李纲《病牛》"] +["迟日园林悲昔游,今春花鸟作边愁。","杜审言《渡湘江》"] +["我有迷魂招不得,雄鸡一声天下白。","李贺《致酒行》"] +["文章合为时而著,歌诗合为事而作。","白居易《与元九书》"] +["要看银山拍天浪,开窗放入大江来。","曾公亮《宿甘露寺僧舍》"] +["看似寻常最奇崛,成如容易却艰辛。","王安石《题张司业诗》"] +["不戚戚于贫贱,不汲汲于富贵。","陶渊明《五柳先生传》"] +["老骥伏枥,志在千里。","曹操《龟虽寿 / 神龟虽寿》"] +["本是同根生,相煎何太急?","曹植《七步诗》"] +["一日不见,如三月兮。","佚名《子衿》"] +["远岫出山催薄暮,细风吹雨弄轻阴。梨花欲谢恐难禁。","李清照《浣溪沙·小院闲窗春色深》"] +["香雾云鬟湿,清辉玉臂寒。","杜甫《月夜》"] +["吴楚东南坼,乾坤日夜浮。","杜甫《登岳阳楼》"] +["文章千古事,得失寸心知。","杜甫《偶题》"] +["此地一为别,孤蓬万里征。","李白《送友人》"] +["晴川历历汉阳树,芳草萋萋鹦鹉洲。","崔颢《黄鹤楼 / 登黄鹤楼》"] +["黯然销魂者,唯别而已矣!","江淹《别赋》"] +["俱怀逸兴壮思飞,欲上青天览明月。","李白《宣州谢脁楼饯别校书叔云》"] +["两岸青山相对出,孤帆一片日边来。","李白《望天门山》"] +["兴酣落笔摇五岳,诗成笑傲凌沧洲。","李白《江上吟》"] +["马上相逢无纸笔,凭君传语报平安。","岑参《逢入京使》"] +["世有伯乐,然后有千里马。千里马常有,而伯乐不常有。","韩愈《杂说四·马说》"] +["得欢当作乐,斗酒聚比邻。","陶渊明《杂诗·人生无根蒂》"] +["欲持一瓢酒,远慰风雨夕。","韦应物《寄全椒山中道士》"] +["强欲登高去,无人送酒来。","岑参《行军九日思长安故园》"] +["盘飧市远无兼味,樽酒家贫只旧醅。","杜甫《客至》"] +["鲁酒不可醉,齐歌空复情。","李白《沙丘城下寄杜甫》"] +["把酒对斜日,无语问西风。","杨炎正《水调歌头·把酒对斜日》"] +["歌中醉倒谁能恨,唱罢归来酒未消。","晏几道《鹧鸪天·小令尊前见玉箫》"] +["险韵诗成,扶头酒醒,别是闲滋味。","李清照《念奴娇·春情》"] +["莫辞盏酒十分劝,只恐风花一片飞。","程颢《郊行即事》"] +["汉寝唐陵无麦饭,山溪野径有梨花。","赵鼎《寒食》"] +["夜来能有几多寒,已瘦了、梨花一半。","黄升《鹊桥仙·春情》"] +["晓窗移枕,酒困香残,春阴帘卷。","吴文英《烛影摇红·元夕雨》"] +["野旷云连树,天寒雁聚沙。","文森《九日》"] +["翅低白雁飞仍重,舌涩黄鹂语未成。","白居易《南湖早春》"] +["孤雁飞南游,过庭长哀吟。","曹植《杂诗六首·其一》"] +["夜如何其夜漫漫,邻鸡未鸣寒雁度。","刘禹锡《更衣曲》"] +["城上日出群乌飞,鸦鸦争赴朝阳枝。","柳宗元《跂乌词》"] +["鸟雀不知郊野好,穿花翻恋小庭中。","高珩《春日杂咏》"] +["最是多情汴堤柳,春来依旧带栖鸦!","曾棨《维扬怀古》"] +["上林消息好,鸿雁已归来。","张元干《临江仙·送宇文德和被召赴行在所》"] +["一半黄梅杂雨晴,虚岚浮翠带湖明,闲云高鸟共身轻。","郑文焯《浣溪沙·从石楼石壁往来邓尉山中》"] +["鼓吹助清赏,鸿雁起汀洲。","苏辙《水调歌头·徐州中秋》"] +["树头幽鸟,对调新语,语罢双飞却。","顾德辉《青玉案·春寒恻恻春阴薄》"] +["燕子不知人去也,飞认阑干。","韩疁《浪淘沙·莫上玉楼看》"] +["回首天涯,一抹斜阳,数点寒鸦。","张可久《折桂令·九日》"] +["秋色雁声愁几许,都在斜阳。","吴文英《浪淘沙·九日从吴见山觅酒》"] +["黄鹂惊梦破,青鸟唤春还。","张宏范《临江仙·忆旧》"] +["白鸟悠悠自去,汀洲外、无限蒹葭。","王国维《满庭芳·水抱孤城》"] +["杨花落,燕子横穿朱阁。","李清臣《谒金门·杨花落》"] +["气软来风易,枝繁度鸟迟。","李爽《芳树》"] +["雁风自劲,云气不上凉天。","吴文英《新雁过妆楼·中秋后一夕李方庵月庭延客命小妓过新水令坐间赋词》"] +["渔父天寒网罟冻,莫徭射雁鸣桑弓。","杜甫《岁晏行》"] +["孤舟向广武,一鸟归成皋。","岑参《巩北秋兴寄崔明允》"] +["白马绕旌旗,悲鸣相追攀。","李白《豫章行》"] +["城头一片西山月,多少征人马上看。","李攀龙《塞上曲送元美》"] +["曲岸持觞,垂杨系马,此地曾经别。","辛弃疾《念奴娇·书东流村壁》"] +["傍枯林古道,长河饮马,此意悠悠。","张炎《八声甘州·记玉关踏雪事清游》"] +["马上墙头,纵教瞥见,也难相认。","晁端礼《水龙吟·咏月》"] +["记得短亭归马,暮衙蜂闹。","吴文英《天香·蜡梅》"] +["此地朝来饯行者,翻向此中牧征马。","孟浩然《高阳池送朱二》"] +["征马分飞日渐斜,见此空为人所嗟。","孟浩然《高阳池送朱二》"] +["此心随去马,迢递过千峰。","方干《送从兄郜 / 韦郜 / 途中别孙璐》"] +["公乎跃马扬玉鞭,灭没高蹄日千里。","温庭筠《拂舞词 / 公无渡河》"] +["至今窥牧马,不敢过临洮。","西鄙人《哥舒歌》"] +["野战格斗死,败马号鸣向天悲。","李白《战城南》"] +["乘我大宛马,抚我繁弱弓。","张华《壮士篇》"] +["匹马南来渡浙河,汴城宫阙远嵯峨。","于谦《岳忠武王祠》"] +["青苔满阶砌,白鸟故迟留。","杜牧《题扬州禅智寺》"] +["燕子欲归时节,高楼昨夜西风。","晏殊《破阵子·燕子欲归时节》"] +["惟有南来无数雁,和明月、宿芦花。","邓剡《唐多令·雨过水明霞》"] +["见说马家滴粉好,试灯风里卖元宵。","符曾《上元竹枝词》"] +["陪都歌舞迎佳节,遥祝延安景物华。","董必武《元旦口占用柳亚子怀人韵》"] +["想今宵、也对新月,过轻寒、何处小桥。","赵汝茪《恋绣衾·柳丝空有千万条》"] +["不求见面惟通谒,名纸朝来满敝庐。","文征明《拜年》"] +["一年滴尽莲花漏。碧井酴酥沈冻酒。","毛滂《玉楼春·己卯岁元日》"] +["郎到断桥须有路,侬住处,柳如金。","张惠言《江城子·填张春溪西湖竹枝词》"] +["时有官船桥畔过,白鸥飞去落前滩。","姜夔《钓雪亭》"] +["潮打三更瓜步月,雨荒十里红桥火。","郑燮《满江红·思家》"] +["小桥横截,缺月初弓。","辛弃疾《沁园春·灵山斋庵赋时筑偃湖未成》"] +["情共天涯秋黯,朱桥锁深巷。","吴文英《解蹀躞·醉云又兼醒雨》"] +["莫惊鸥鹭,四桥尽是,老子经行处。","苏轼《青玉案·送伯固归吴中》"] +["试浇桥下水,今夕到湘中。","陈与义《临江仙·高咏楚词酬午日》"] +["一身犹在,乱山深处,寂寞溪桥畔。","黄公绍《青玉案·年年社日停针线》"] +["六曲阑干偎碧树,杨柳风轻,展尽黄金缕。","冯延巳《鹊踏枝·清明》"] +["野棠花落,又匆匆过了,清明时节。","辛弃疾《念奴娇·书东流村壁》"] +["清明节,雨晴天,得意正当年。","薛昭蕴《喜迁莺·清明节》"] +["彩线轻缠红玉臂,小符斜挂绿云鬟。","苏轼《浣溪沙·端午》"] +["寒食后,酒醒却咨嗟。","苏轼《望江南·超然台作》"] +["梨花自寒食,进节只愁余。","杨万里《寒食上冢》"] +["将坛醇酒冰浆细,元夜邀宾灯火新。","赵时春《元宵饮陶总戎家二首》"] +["霭芳阴未解,乍天气、过元宵。","周端臣《木兰花慢·送人之官九华》"] +["春游浩荡,是年年、寒食梨花时节。","丘处机《无俗念·灵虚宫梨花词》"] +["不知何处火,来就客心然。","沈佺期《寒食》"] +["起舞徘徊风露下,今夕不知何夕。","苏轼《念奴娇·中秋》"] +["寻思不似鹊桥人,犹自得、一年一度。","赵以夫《鹊桥仙·富沙七夕为友人赋》"] +["幸回郎意且斯须,一年中别今始初。","王建《七夕曲》"] +["每恨蟪蛄怜婺女,几回娇妒下鸳机,今宵嘉会两依依。","毛文锡《浣溪沙·七夕年年信不违》"] +["不效艾符趋习俗,但祈蒲酒话升平。","殷尧藩《端午日》"] +["斟酌姮娥,九秋宫殿冷。","史达祖《齐天乐·中秋宿真定驿》"] +["寄言俦侣,莫负广寒沈醉。","文征明《念奴娇·中秋对月》"] +["遮莫圆明似前度,不知谁续广寒游。","慧霖《闰中秋玩月》"] +["每圆处即良宵,甚此夕偏饶,对歌临怨。","吴文英《玉漏迟·瓜泾度中秋夕赋》"] +["九日登高处,群山入望赊。","赵时春《原州九日》"] +["紫萸一枝传赐,梦谁到、汉家陵。","姚云文《紫萸香慢·近重阳》"] +["黄莺也爱新凉好,飞过青山影里啼。","徐玑《新凉》"] +["云边雁断胡天月,陇上羊归塞草烟。","温庭筠《苏武庙》"] +["平冈细草鸣黄犊,斜日寒林点暮鸦。","辛弃疾《鹧鸪天·陌上柔桑破嫩芽》"] +["鸟雀呼晴,侵晓窥檐语。","周邦彦《苏幕遮·燎沉香》"] +["握手经年别,惊心九日霜。","妙信《九日酬诸子》"] +["算明朝、未了重阳,紫萸应耐看。","吴文英《霜花腴·重阳前一日泛石湖》"] +["朔方三度重阳节,河曲干旌岁岁忙。","齐之鸾《九日登清水营城》"] +["暗凝伫。近重阳、满城风雨。","周密《扫花游·九日怀归》"] +["半壁见海日,空中闻天鸡。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["犹记得、当年深隐,门掩两三株。","张炎《渡江云·山阴久客一再逢春回忆西杭渺然愁思》"] +["数年前也放狂,这几日全无况。","康海《雁儿落带得胜令·饮中闲咏》"] +["念去来、岁月如流,徘徊久、叹息愁思盈。","周邦彦《绮寮怨·上马人扶残醉》"] +["纵收香藏镜,他年重到,人面桃花在否。","袁去华《瑞鹤仙·郊原初过雨》"] +["多事年年二月风,翦出鹅黄缕。","纳兰性德《卜算子·新柳》"] +["暗数十年湖上路,能几度,著娉婷。","卢祖皋《江城子·画楼帘暮卷新晴》"] +["碧湖湖上柳阴阴,人影澄波浸,常记年时欢花饮。","杨果《小桃红·碧湖湖上柳阴阴》"] +["一郡官闲唯副使,一年冷节是清明。","王禹偁《清明日独酌》"] +["寂寂柴门村落里,也教插柳记年华。","赵鼎《寒食》"] +["野旷天清无战声,四万义军同日死。","杜甫《悲陈陶》"] +["群鸡正乱叫,客至鸡斗争。","杜甫《羌村三首·其三》"] +["突营射杀呼延将,独领残兵千骑归。","李白《从军行》"] +["誓将挂冠去,觉道资无穷。","岑参《与高适薛据同登慈恩寺浮图》"] +["闲门向山路,深柳读书堂。","刘昚虚《阙题》"] +["弱龄寄事外,委怀在琴书。","陶渊明《始作镇军参军经曲阿作》"] +["水涨鱼天拍柳桥。云鸠拖雨过江皋。","周邦彦《浣溪沙·水涨鱼天拍柳桥》"] +["独立扬新令,千营共一呼。","卢纶《和张仆射塞下曲·其一》"] +["五月虽热麦风清,檐头索索缲车鸣。","王建《田家行》"] +["桑柘废来犹纳税,田园荒后尚征苗。","杜荀鹤《山中寡妇 / 时世行》"] +["千层石树遥行路,一带山田放水声。","姚鼐《山行·布谷飞飞劝早耕》"] +["登车宿迁北,万顷铺琼田。","陆文圭《宿迁道中遇雪》"] +["麦收上场绢在轴,的知输得官家足。","王建《田家行》"] +["白草黄沙。月照孤村三两家。","蒋氏女《减字木兰花·题雄州驿》"] +["时见归村人,沙行渡头歇。","孟浩然《秋登兰山寄张五》"] +["锦里烟尘外,江村八九家。","杜甫《为农》"] +["久在樊笼里,复得返自然。","陶渊明《归园田居·其一》"] +["纵我不往,子宁不嗣音?","佚名《子衿》"] +["恸哭六军俱缟素,冲冠一怒为红颜。","吴伟业《圆圆曲》"] +["谁家今夜扁舟子?","张若虚《春江花月夜》"] +["行路难!行路难!多歧路,今安在?","李白《行路难三首》"] +["人生如此自可乐,岂必局束为人鞿?","韩愈《山石》"] +["弄笔偎人久,描花试手初。","欧阳修《南歌子·凤髻金泥带》"] +["檀郎故相恼,须道花枝好。","佚名《菩萨蛮·牡丹含露真珠颗》"] +["燕赵多佳人,美者颜如玉。","佚名《东城高且长》"] +["以闲为自在,将寿补蹉跎。","刘禹锡《岁夜咏怀》"] +["唤起一天明月,照我满怀冰雪,浩荡百川流。","辛弃疾《水调歌头·和马叔度游月波楼》"] +["月台花榭,琐窗朱户,只有春知处。","贺铸《青玉案·凌波不过横塘路》"] +["会挽雕弓如满月,西北望,射天狼。","苏轼《江城子·密州出猎》"] +["持节云中,何日遣冯唐?","苏轼《江城子·密州出猎》"] +["投我以木桃,报之以琼瑶。","佚名《木瓜》"] +["天尽头,何处有香丘?","曹雪芹《葬花吟》"] +["风也萧萧,雨也萧萧,瘦尽灯花又一宵。","纳兰性德《采桑子·谁翻乐府凄凉曲》"] +["尧之都,舜之壤,禹之封。","陈亮《水调歌头·送章德茂大卿使虏》"] +["本来无一物,何处惹尘埃!","惠能《菩提偈》"] +["陌上谁家年少足风流?","韦庄《思帝乡·春日游》"] +["谁道闲情抛掷久?","冯延巳《鹊踏枝·谁道闲情抛掷久》"] +["出其东门,有女如云。","佚名《出其东门》"] +["绸缪束薪,三星在天。","佚名《绸缪》"] +["有匪君子,如切如磋,如琢如磨。","佚名《淇奥》"] +["女曰鸡鸣,士曰昧旦。","佚名《女曰鸡鸣》"] +["月出皎兮,佼人僚兮。","佚名《月出》"] +["嘤其鸣矣,求其友声。","佚名《伐木》"] +["岂曰无衣?六兮。","佚名《无衣》"] +["河汉清且浅,相去复几许!","佚名《迢迢牵牛星》"] +["从今以往,勿复相思,相思与君绝!","佚名《有所思》"] +["匏有苦叶,济有深涉。","佚名《匏有苦叶》"] +["千载琵琶作胡语,分明怨恨曲中论。","杜甫《咏怀古迹五首·其三》"] +["复值接舆醉,狂歌五柳前。","王维《辋川闲居赠裴秀才迪》"] +["拟把疏狂图一醉,对酒当歌,强乐还无味。","柳永《蝶恋花·伫倚危楼风细细》"] +["昨夜西风凋碧树,独上高楼,望尽天涯路。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["抽刀断水水更流,举杯销愁愁更愁。","李白《宣州谢脁楼饯别校书叔云》"] +["人生在世不称意,明朝散发弄扁舟。","李白《宣州谢脁楼饯别校书叔云》"] +["有女怀春,吉士诱之。","佚名《野有死麕》"] +["终风且曀,不日有曀,寤言不寐,愿言则嚏。","佚名《终风》"] +["于嗟阔兮,不我活兮。","佚名《击鼓》"] +["凯风自南,吹彼棘薪。","佚名《凯风》"] +["忧心悄悄,愠于群小。","佚名《柏舟》"] +["击鼓其镗,踊跃用兵。","佚名《击鼓》"] +["雍雍鸣雁,旭日始旦。","佚名《匏有苦叶》"] +["敲成玉磬穿林响,忽作玻璃碎地声。","杨万里《稚子弄冰》"] +["泛彼柏舟,亦泛其流。","佚名《柏舟》"] +["绿兮衣兮,绿衣黄里。","佚名《绿衣》"] +["素面翻嫌粉涴,洗妆不褪唇红。","苏轼《西江月·梅花》"] +["凌波不过横塘路,但目送、芳尘去。","贺铸《青玉案·凌波不过横塘路》"] +["夸赴军中宴,走马去如云。","白居易《轻肥》"] +["长门事,准拟佳期又误。","辛弃疾《摸鱼儿·更能消几番风雨》"] +["采采卷耳,不盈顷筐。","佚名《卷耳》"] +["窈窕淑女,寤寐求之。","佚名《关雎》"] +["嘒彼小星,三五在东。","佚名《小星》"] +["有子七人,莫慰母心。","佚名《凯风》"] +["雄雉于飞,泄泄其羽。","佚名《雄雉》"] +["载驰载驱,归唁卫侯。","佚名《载驰》"] +["凯风自南,吹彼棘心。","佚名《凯风》"] +["硕人敖敖,说于农郊。","佚名《硕人》"] +["瞻彼淇奥,绿竹青青。","佚名《淇奥》"] +["谁谓河广?一苇杭之。","佚名《河广》"] +["巧笑之瑳,佩玉之傩。","佚名《竹竿》"] +["投我以木瓜,报之以琼琚。","佚名《木瓜》"] +["凡民有丧,匍匐救之。","佚名《谷风》"] +["静女其娈,贻我彤管。","佚名《静女》"] +["何必桑乾方是远,中流以北即天涯!","杨万里《初入淮河四绝句》"] +["靖康耻,犹未雪。","岳飞《满江红·写怀》"] +["剑阁峥嵘而崔嵬,一夫当关,万夫莫开。","李白《蜀道难》"] +["嫁与东风春不管,凭尔去,忍淹留!","曹雪芹《唐多令·柳絮》"] +["悲哉,秋之为气也!","宋玉《九辩》"] +["角声满天秋色里,塞上燕脂凝夜紫。","李贺《雁门太守行》"] +["钟鼓馔玉不足贵,但愿长醉不复醒。","李白《将进酒》"] +["采采芣苢,薄言采之。","佚名《芣苢》"] +["人扶醉,月依墙,是当初、谁敢疏狂!","史达祖《夜合花·柳锁莺魂》"] +["落地为兄弟,何必骨肉亲!","陶渊明《杂诗》"] +["譬如朝露,去日苦多。","曹操《短歌行》"] +["北上太行山,艰哉何巍巍!","曹操《苦寒行》"] +["东家有贤女,自名秦罗敷。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["况浩然者,乃天地之正气也,作正气歌一首。","文天祥《正气歌》"] +["洪波浩荡迷旧国,路远西归安可得!","李白《梁园吟》"] +["佛性常清净,何处有尘埃!","惠能《菩提偈》"] +["心是菩提树,身为明镜台。","惠能《菩提偈》"] +["明镜本清净,何处染尘埃!","惠能《菩提偈》"] +["红满苔阶绿满枝,杜宇声声,杜宇声悲!","唐寅《一剪梅·雨打梨花深闭门》"] +["葛生蒙楚,蔹蔓于野。","佚名《葛生》"] +["蒹葭萋萋,白露未晞。","佚名《蒹葭》"] +["王于兴师,修我甲兵。","佚名《秦风·无衣》"] +["洵有情兮,而无望兮。","佚名《宛丘》"] +["月出照兮,佼人燎兮。","佚名《月出》"] +["每有良朋,况也永叹。","佚名《常棣》"] +["蓼彼萧斯,零露湑兮。","佚名《蓼萧》"] +["呦呦鹿鸣,食野之苹。","佚名《小雅·鹿鸣》"] +["芳春平仲绿,清夜子规啼。","沈佺期《夜宿七盘岭》"] +["山有扶苏,隰有荷华。","佚名《山有扶苏》"] +["彼汾一方,言采其桑。","佚名《汾沮洳》"] +["蟋蟀在堂,岁聿其莫。","佚名《蟋蟀》"] +["百岁之后,归于其室。","佚名《葛生》"] +["野有蔓草,零露漙兮。","佚名《野有蔓草》"] +["风雨凄凄,鸡鸣喈喈。","佚名《风雨》"] +["父母何尝?悠悠苍天,曷其有常?","佚名《鸨羽》"] +["言念君子,温其如玉。","佚名《小戎》"] +["行迈靡靡,中心如噎。","佚名《黍离》"] +["缁衣之好兮,敝予又改造兮。","佚名《缁衣》"] +["邂逅相遇,与子偕臧。","佚名《野有蔓草》"] +["成不以富,亦祗以异。","佚名《我行其野》"] +["秩秩斯干,幽幽南山。","佚名《斯干》"] +["我有嘉宾,中心好之。","佚名《彤弓》"] +["嗟尔君子,无恒安息。","佚名《小明》"] +["鸳鸯于飞,毕之罗之。","佚名《鸳鸯》"] +["有鹙在梁,有鹤在林。","佚名《白华》"] +["鹤鸣于九皋,声闻于野。","佚名《鹤鸣》"] +["我不见兮,云何盱矣。","佚名《都人士》"] +["厥初生民,时维姜嫄。","佚名《大雅·生民》"] +["颙颙卬卬,如圭如璋,令闻令望。","佚名《卷阿》"] +["如彼遡风,亦孔之僾。","佚名《桑柔》"] +["每到春来,惆怅还依旧。","冯延巳《鹊踏枝·谁道闲情抛掷久》"] +["妾拟将身嫁与一生休。","韦庄《思帝乡·春日游》"] +["莽红尘,何处觅知音?","秋瑾《满江红·小住京华》"] +["问何物、能令公喜?","辛弃疾《贺新郎·甚矣吾衰矣》"] +["但得众生皆得饱,不辞羸病卧残阳。","李纲《病牛》"] +["叵耐灵鹊多谩语,送喜何曾有凭据?","佚名《鹊踏枝·叵耐灵鹊多谩语》"] +["自送别,心难舍,一点相思几时绝?","关汉卿《四块玉·别情》"] +["千万恨,为君剖。","顾贞观《金缕曲二首》"] +["屠狗功名,雕龙文卷,岂是平生意?","龚自珍《湘月·天风吹我》"] +["东风冷,香远茜裙归。","姜夔《小重山令·赋潭州红梅》"] +["折芦花赠远,零落一身秋。","张炎《八声甘州·记玉关踏雪事清游》"] +["是别有人间,那边才见,光影东头?","辛弃疾《木兰花慢·可怜今夕月》"] +["一从陶令评章后,千古高风说到今。","曹雪芹《咏菊》"] +["半世交亲随逝水,几人图画入凌烟?","黄庭坚《次元明韵寄子由》"] +["孟子曰:「吾善养吾浩然之气。","文天祥《正气歌》"] +["休言举世无谈者,解语何妨话片时。","曹雪芹《问菊》"] +["彼君子兮,不素餐兮!","佚名《伐檀》"] +["万缕千丝终不改,任他随聚随分。","曹雪芹《临江仙·柳絮》"] +["肠断月明红豆蔻,月似当时,人似当时否?","纳兰性德《鬓云松令·枕函香》"] +["近来无限伤心事,谁与话长更?","纳兰性德《青衫湿·悼亡》"] +["难逢易散花间酒,饮罢空搔首。","纳兰性德《虞美人·风灭炉烟残灺冷》"] +["试看春残花渐落,便是红颜老死时。","曹雪芹《葬花吟》"] +["黎人本同性,云何发祸机?","钱嶫《悯黎咏》"] +["瓦砾明珠一例抛,何曾石尉重娇娆?","曹雪芹《五美吟·绿珠》"] +["目尽青天怀今古,肯儿曹恩怨相尔汝!","张元干《贺新郎·送胡邦衡待制赴新州》"] +["举大白,听《金缕》。","张元干《贺新郎·送胡邦衡待制赴新州》"] +["孤标傲世偕谁隐,一样花开为底迟?","曹雪芹《问菊》"] +["多情自古伤离别,更那堪,冷落清秋节!","柳永《雨霖铃·寒蝉凄切》"] +["一身转战三千里,一剑曾当百万师。","王维《老将行》"] +["飘风骤雨惊飒飒,落花飞雪何茫茫!","李白《草书歌行》"] +["彼苍者天,歼我良人!","佚名《黄鸟》"] +["夕阳西下几时回?","晏殊《浣溪沙·一曲新词酒一杯》"] +["荡涤放情志,何为自结束?","佚名《东城高且长》"] +["用舍由时,行藏在我,袖手何妨闲处看。","苏轼《沁园春·孤馆灯青》"] +["天不老,人未偶。","李之仪《谢池春·残寒销尽》"] +["求田问舍,怕应羞见,刘郎才气。","辛弃疾《水龙吟·登建康赏心亭》"] +["何人为我楚舞,听我楚狂声?","辛弃疾《水调歌头·壬子三山被召陈端仁给事饮饯席上作》"] +["铸就而今相思错,料当初、费尽人间铁。","辛弃疾《贺新郎·把酒长亭说》"] +["夜已三更,金波淡,玉绳低转。","苏轼《洞仙歌·冰肌玉骨》"] +["六国扰,三秦扫,初谓商山遗四老。","贺铸《将进酒·城下路》"] +["尽道有些堪恨处,无情,任是无情也动人。","秦观《南乡子·妙手写徽真》"] +["月桥花院,琐窗朱户,只有春知处。","贺铸《青玉案·凌波不过横塘路》"] +["试问闲情都几许?","贺铸《青玉案·凌波不过横塘路》"] +["飞絮落花时候、一登楼。","秦观《江城子·西城杨柳弄春柔》"] +["头上貂蝉贵客,苑外麒麟高冢,人世竟谁雄?","辛弃疾《水调歌头·我饮不须劝》"] +["一旦归为臣虏,沈腰潘鬓消磨。","李煜《破阵子·四十年来家国》"] +["我来正逢秋雨节,阴气晦昧无清风。","韩愈《谒衡岳庙遂宿岳寺题门楼》"] +["请君暂上凌烟阁,若个书生万户侯?","李贺《南园十三首》"] +["人生如寄,多忧何为?","曹丕《善哉行·其一》"] +["今我不乐,岁月如驰。","曹丕《善哉行·其一》"] +["等闲妨了绣功夫,笑问鸳鸯两字怎生书?","欧阳修《南歌子·凤髻金泥带》"] +["归去来兮,吾归何处?","苏轼《满庭芳·归去来兮》"] +["人生代代无穷已,江月年年望相似。","张若虚《春江花月夜》"] +["桃花一簇开无主,可爱深红爱浅红?","杜甫《江畔独步寻花七绝句》"] +["又是春残也,如何出翠帏?","翁宏《春残》"] +["欢若见怜时,棺木为侬开。","佚名《华山畿·君既为侬死》"] +["一望关河萧索,千里清秋,忍凝眸?","柳永《曲玉管·陇首云飞》"] +["昭王白骨萦蔓草,谁人更扫黄金台?","李白《行路难三首》"] +["气岸遥凌豪士前,风流肯落他人后?","李白《流夜郎赠辛判官》"] +["桃李竟何言,终成南山皓。","李白《览镜书怀》"] +["心乎爱矣,遐不谓矣?","佚名《隰桑》"] +["鸡栖于埘,日之夕矣,羊牛下来。","佚名《君子于役》"] +["金玉其外,败絮其中","刘基《卖柑者言》"] +["不要人夸好颜色,只流清气满乾坤。","王冕《墨梅》"] +["今夕何夕,见此良人?","佚名《绸缪》"] +["人生贵相知,何必金与钱?","李白《赠友人三首》"] +["人无兄弟,胡不佽焉?","佚名《杕杜》"] +["深村时节好,应为去年丰。","范成大《寒食郊行书事》"] +["流水孤村,荒城古道。","陈霆《踏莎行·晚景》"] +["黄鸡白酒,君去村社一番秋。","辛弃疾《水调歌头·送杨民瞻》"] +["念寒蛩残梦,归鸿心事,那听江村夜笛。","吴文英《瑞鹤仙·秋感》"] +["重五山村好,榴花忽已繁。","陆游《乙卯重五诗》"] +["驱鸡上树木,始闻叩柴荆。","杜甫《羌村三首·其三》"] +["一日官军收海服,驱牛驾车食牛肉,归来攸得牛两角。","元稹《田家词 / 田家行》"] +["水村山馆,夜阑无寐,听尽空阶雨。","黄大临《青玉案·和贺方回韵送伯固还吴中》"] +["厌见千门万户,经过北里南邻。","王维《田园乐七首·其一》"] +["郭门临渡头,村树连谿口。","王维《新晴野望》"] +["远牧牛,绕村四面禾黍稠。","李涉《牧童词》"] +["书剑时将晚,丘园日已暮。","孟浩然《田园作》"] +["勤苦守恒业,始有数月粮。","黄燮清《秋日田家杂咏》"] +["重铸锄犁作斤,姑舂妇担去轮官,输官不足归卖屋。","元稹《田家词 / 田家行》"] +["君看获稻时,粒粒脂膏香。","黄燮清《秋日田家杂咏》"] +["狗吠深巷中,鸡鸣桑树颠。","陶渊明《归园田居·其一》"] +["山荒聊可田,钱镈还易办。","王守仁《谪居粮绝请学于农将田南山咏言寄怀》"] +["卜邻近三径,植果盈千树。","孟浩然《田园作》"] +["持斧伐远扬,荷锄觇泉脉。","王维《春中田园作》"] +["高田如楼梯,平田如棋局。","杨慎《出郊》"] +["鸟衔野田草,误入枯桑里。","李白《树中草》"] +["营州少年厌原野,狐裘蒙茸猎城下。","高适《营州歌》"] +["野幕敞琼筵,羌戎贺劳旋。","卢纶《和张仆射塞下曲·其四》"] +["拔剑击大荒,日收胡马群;","杜甫《后出塞五首》"] +["出塞入塞寒,处处黄芦草。","王昌龄《塞下曲四首》"] +["枫叶千枝复万枝,江桥掩映暮帆迟。","鱼玄机《江陵愁望寄子安》"] +["蓝桥何处觅云英。只有多情流水、伴人行。","苏轼《南歌子·寓意》"] +["我来圯桥上,怀古钦英风。","李白《经下邳圯桥怀张子房》"] +["两岸月桥花半吐。红透肌香,暗把游人误。","真德秀《蝶恋花·两岸月桥花半吐》"] +["念月榭携手,露桥闻笛。","周邦彦《兰陵王·柳》"] +["迳直夫何细!桥危可免扶?","杨万里《寒食上冢》"] +["万里桥边多酒家,游人爱向谁家宿。","张籍《成都曲》"] +["小立红桥柳半垂,越罗裙飏缕金衣。","纳兰性德《山花子·小立红桥柳半垂》"] +["平明日出东南地,满碛寒光生铁衣。","李益《度破讷沙二首·其二 / 塞北行次度破讷沙》"] +["柏台霜威寒逼人,热海炎气为之薄。","岑参《热海行送崔侍御还京》"] +["关西老将不胜愁,驻马听之双泪流。","王维《陇头吟》"] +["风引龙虎旗,歌钟昔追攀。","李白《登邯郸洪波台置酒观发兵》"] +["玉帛朝回望帝乡,乌孙归去不称王。","常建《塞下曲四首·其一》"] +["边兵春尽回,独上单于台。","张蠙《登单于台》"] +["历览千载书,时时见遗烈。","陶渊明《癸卯岁十二月中作与从弟敬远》"] +["良辰当五日,偕老祝千年。","权德舆《端午日礼部宿斋有衣服彩结之贶以诗还答》"] +["儿大诗书女丝麻,公但读书煮春茶。","黄庭坚《送王郎》"] +["谤书盈箧不复辩,脱身来看江南山。","谢逸《送董元达》"] +["人随沙岸向江村,余亦乘舟归鹿门。","孟浩然《夜归鹿门山歌 / 夜归鹿门歌》"] +["俯视洛阳川,茫茫走胡兵。","李白《古风·其十九》"] +["故园便是无兵马,犹有归时一段愁。","陈与义《送人归京师》"] +["记得去年今夕,酾酒溪亭,淡月云来去。","文征明《念奴娇·中秋对月》"] +["年少辞家从冠军,金鞍宝剑去邀勋。","王涯《塞下曲二首·其二》"] +["一年春又尽,倚杖对斜晖。","左纬《春晚》"] +["朱颜那有年年好,逞艳游、赢取如今。","韩疁《高阳台·除夜》"] +["世事悠悠浑未了,年光冉冉今如许。","吴潜《满江红·送李御带珙》"] +["去年战桑干源,今年战葱河道。","李白《战城南》"] +["露蓼香泾,记年时相识。","吴文英《尾犯·甲辰中秋》"] +["年时俯仰过,功名宜速崇。","张华《壮士篇》"] +["日归功未建,时往岁载阴。","陆机《猛虎行》"] +["一年一度,烂漫离披,似长江去浪。","吴澄《渡江云·揭浩斋送春和韵》"] +["记年时、旧宿凄凉,暮烟秋雨野桥寒。","吴文英《霜花腴·重阳前一日泛石湖》"] +["何日请缨提锐旅,一鞭直渡清河洛。","岳飞《满江红·登黄鹤楼有感》"] +["雁过也,正伤心,却是旧时相识。","李清照《声声慢·寻寻觅觅》"] +["疏雨池塘见,微风襟袖知。阴阴夏木啭黄鹂。","贺铸《南歌子·疏雨池塘见》"] +["白头太守真愚甚,满插茱萸望辟邪。","宋祁《九日置酒》"] +["风雨满城,何幸两重阳之近;","文天祥《回董提举中秋请宴启》"] +["万里秋光客兴赊,同人九日惜年华。","沈辂《九日登高台寺》"] +["细看茱萸一笑,诗翁健似常年。","张可久《风入松·九日》"] +["可惜重阳,不把黄花与。","吴文英《蝶恋花·九日和吴见山韵》"] +["又是过重阳,台榭登临处,茱萸香坠。","李煜《谢新恩·冉冉秋光留不住》"] +["淑气催黄鸟,晴光转绿蘋。","杜审言《和晋陵陆丞早春游望》"] +["林中有奇鸟,自言是凤凰。","阮籍《咏怀八十二首·其七十九》"] +["樵人归欲尽,烟鸟栖初定。","孟浩然《宿业师山房期丁大不至 / 宿业师山房待丁大不至》"] +["人行明镜中,鸟度屏风里。","李白《清溪行 / 宣州清溪》"] +["人情已厌南中苦,鸿雁那从北地来。","王勃《蜀中九日 / 九日登高》"] +["玉阶空伫立,宿鸟归飞急。","李白《菩萨蛮·平林漠漠烟如织》"] +["何当击凡鸟,毛血洒平芜。","杜甫《画鹰》"] +["又是重阳近也,几处处,砧杵声催。","秦观《满庭芳·碧水惊秋》"] +["明年此会知谁健?醉把茱萸仔细看。","杜甫《九日蓝田崔氏庄》"] +["绿杯红袖趁重阳。人情似故乡。","晏几道《阮郎归·天边金掌露成霜》"] +["惟有今宵,皓彩皆同普。","郭应祥《醉落魄·丙寅中秋》"] +["琼楼玉宇。分明不受人间暑。寻常岂是无三五。","郭应祥《醉落魄·丙寅中秋》"] +["想见广寒宫殿,正云梳风掠。","辛弃疾《好事近·中秋席上和王路钤》"] +["小住京华,早又是,中秋佳节。","秋瑾《满江红·小住京华》"] +["谁忍窥河汉,迢迢问斗牛。","孟浩然《他乡七夕》"] +["鹊辞穿线月,花入曝衣楼。","李贺《七夕》"] +["七夕年年信不违,银河清浅白云微,蟾光鹊影伯劳飞。","毛文锡《浣溪沙·七夕年年信不违》"] +["千家闭户无砧杵,七夕何人望斗牛。","李嘉祐《早秋京口旅泊章侍御寄书相问因以赠之时七夕》"] +["夜色银河情一片。轻帐偷欢,银烛罗屏怨。","吴文英《凤栖梧·甲辰七夕》"] +["普天皆灭焰,匝地尽藏烟。","沈佺期《寒食》"] +["忆得盈盈拾翠侣,共携赏、凤城寒食。","李甲《帝台春·芳草碧色》"] +["禁烟不到粤人国,上冢亦携庞老家。","赵鼎《寒食》"] +["红深绿暗径相交,抱暖含芳披紫袍。","温庭筠《寒食日作》"] +["岭南能有几多花,寒食临之扫春迹。","郑刚中《寒食》"] +["望千门如昼,嬉笑游冶。","周邦彦《解语花·上元》"] +["贫居往往无烟火,不独明朝为子推。","孟云卿《寒食》"] +["留恋海棠颜色、过清明。","张辑《南歌子·柳户朝云湿》"] +["清明天气。永日愁如醉。","杜安世《鹤冲天·清明天气》"] +["燕归花谢,早因循、又过清明。","纳兰性德《红窗月·燕归花谢》"] +["时霎清明,载花不过西园路。","吴文英《点绛唇·时霎清明》"] +["阑风伏雨催寒食,樱桃一夜花狼藉。","纳兰性德《菩萨蛮·阑风伏雨催寒食》"] +["试上吴门窥郡郭,清明几处有新烟。","张继《闾门即事》"] +["清明时节雨声哗。潮拥渡头沙。","张炎《朝中措·清明时节》"] +["危桥属幽径,缭绕穿疏林。","柳宗元《巽公院五咏·苦竹桥》"] +["襄阳古道灞陵桥,诗兴与秋高。","完颜璹《朝中措·襄阳古道灞陵桥》"] +["桥成汉渚星波外,人在鸾歌凤舞前。","晏几道《鹧鸪天·当日佳期鹊误传》"] +["策马自沙漠,长驱登塞垣。","高适《蓟中作》"] +["门外垂杨岸侧,画桥谁系兰舟。","万俟咏《木兰花慢·恨莺花渐老》"] +["笑拈芳草不知名,乍凌波、断桥西堍。","吴文英《西子妆慢·湖上清明薄游》"] +["碧溪影里小桥横,青帘市上孤烟起。","谢逸《踏莎行·柳絮风轻》"] +["夜吟敲落霜红,船傍枫桥系。","吴文英《荔枝香近·送人游南徐》"] +["凄凉南浦,断桥斜月。","房舜卿《忆秦娥·与君别》"] +["门外平桥连柳堤,归来晚树黄莺啼。","温庭筠《春洲曲》"] +["二十四桥,颇有杜书记否。","刘辰翁《桂枝香·吹箫人去》"] +["红桥路,正一派、画船萧鼓中流住。","纳兰性德《摸鱼儿·午日雨眺》"] +["短长亭子短长桥,桥外垂杨一万条。","钱枚《忆王孙·短长亭子短长桥》"] +["昨夜斗回北,今朝岁起东。","孟浩然《田家元日》"] +["惜别浮桥驻马时,举头试望南山岭。","李颀《少室雪晴送王宁》"] +["冷冷水向桥东去。漠漠云归溪上住。","刘镇《玉楼春·东山探梅》"] +["月色灯山满帝都,香车宝盖隘通衢。","李商隐《观灯乐行》"] +["人归山郭暗,雁下芦洲白。","韦应物《夕次盱眙县》"] +["喧啾百鸟群,忽见孤凤凰。","韩愈《听颖师弹琴》"] +["行舟逗远树,度鸟息危樯。","阴铿《渡青草湖》"] +["遥想独游佳味少,无方骓马但鸣嘶。","苏辙《怀渑池寄子瞻兄》"] +["铁马红旗寒日暮,使君犹寄边城。","周紫芝《临江仙·送光州曾使君》"] +["悠悠卷旆旌,饮马出长城。","李世民《饮马长城窟行》"] +["闻道长安灯夜好,雕轮宝马如云。","毛滂《临江仙·都城元夕》"] +["此时却羡闲人醉,五马无由入酒家。","白居易《早冬》"] +["东指羲和能走马,海尘新生石山下。","李贺《天上谣》"] +["马蹄踏水乱明霞,醉袖迎风受落花。","刘因《山家·马蹄踏水乱明霞》"] +["脉脉广川流,驱马历长洲。","上官仪《入朝洛堤步月》"] +["马穿杨柳嘶,人倚秋千笑,探莺花总教春醉倒。","王磐《清江引·清明日出游》"] +["来相召、香车宝马,谢他酒朋诗侣。","李清照《永遇乐·落日熔金》"] +["万马不嘶,一声寒角,令行柳营。","刘过《沁园春·张路分秋阅》"] +["辔压马头金错落,鞍笼驼背锦斓班。","金德淑《望江南·春睡起》"] +["君马黄,我马白。","李白《君马黄》"] +["月傍苑楼灯影暗,风传阁道马蹄回。","王守仁《元夕二首》"] +["常随宝镫陪金勒,绝塞指挥万马嘶。","俞益谟《中洲株柳》"] +["哀蝉无留响,丛雁鸣云霄。","陶渊明《己酉岁九月九日》"] +["向来吟秀句,不觉已鸣鸦。","韩翃《酬程延秋夜即事见赠》"] +["细草软沙溪路、马蹄轻。","苏轼《南歌子·寓意》"] +["绿暗红稀春已暮,燕子衔泥,飞入垂杨处。","葛长庚《凤栖梧·绿暗红稀春已暮》"] +["龟灵未免刳肠患,马失应无折足忧。","白居易《放言五首·其二》"] +["厩马散连山,军容威绝域。","李白《君子有所思行》"] +["铁马云雕久绝尘,柳营高压汉营春。","温庭筠《过五丈原 / 经五丈原》"] +["马骄泥软锦连乾,香袖半笼鞭。","薛昭蕴《喜迁莺·清明节》"] +["相逢处,自有暗尘随马。","周邦彦《解语花·上元》"] +["嘶马摇鞭何处去?晓禽霜满树。","牛希济《谒金门·秋已暮》"] +["记少年、骏马走韩卢,掀东郭。","辛弃疾《满江红·和范先之雪》"] +["未羞他、双燕归来,画帘半卷。","张炎《解连环·孤雁》"] +["无情燕子,怕春寒、轻失花期。","李邴《汉宫春·梅》"] +["王粲登临寥落际。雁飞不断天连水。","王庭筠《凤栖梧·衰柳疏疏苔满地》"] +["别路琴声断,秋山猿鸟吟。","卢照邻《送梓州高参军还京》"] +["鸟声有悲欢,我爱口流血。","孟郊《苦寒吟》"] +["西风恶。数声新雁,数声残角。","黄机《忆秦娥·秋萧索》"] +["记取江湖泊船处,卧闻新雁落寒汀。","陆游《夜泊水村》"] +["何似当筵虎士,挥手弦声响处,双雁落遥空。","叶梦得《水调歌头·九月望日与客习射西园余偶病不能射》"] +["惊鸟去无际,寒蛩鸣我傍。","马戴《楚江怀古三首》"] +["旧时王谢、堂前燕子,飞向谁家。","吴激《人月圆·南朝千古伤心事》"] +["举头忽见衡阳雁。千声万字情何限。","陈达叟《菩萨蛮·举头忽见衡阳雁》"] +["雁过孤峰晓,猿啼一树霜。","贾岛《送天台僧》"] +["风日薄、度墙啼鸟声乱。","卢祖皋《宴清都·初春》"] +["楼殿无人春画长。燕子归来依旧忙。","谢克家《忆君王·依依宫柳拂宫墙》"] +["燕帘莺户,云窗雾阁,酒醒啼鸦。","张炎《朝中措·清明时节》"] +["白鸟明边帆影直。隔江闻夜笛。","周密《闻鹊喜·吴山观涛》"] +["朔风万里入衣多,嘹呖寒空一雁过。","石茂华《九月九日登长城关》"] +["水满池塘花满枝。乱香深里语黄鹂。","赵令《浣溪沙·水满池塘花满枝》"] +["巴山重叠相逢处。燕子占巢花脱树。","张先《渔家傲·和程公辟赠》"] +["月色入高楼,相思两处愁。","秋瑾《菩萨蛮·寄女伴》"] +["芳菲菲而难亏兮,芬至今犹未沬。","屈原《离骚》"] +["老死阡陌间,何因扬清芬。","李白《赠何七判官昌浩》"] +["屈体若无骨,壮心有所凭。","李白《赠新平少年》"] +["英雄者,胸怀大志,腹有良谋,有包藏宇宙之机,吞吐天地之志者也。","《三国演义·第二十一回》"] +["法令至行,公平无私","《战国策·秦一·卫鞅亡魏入秦》"] +["便休休,更说甚,是和非!","辛弃疾《最高楼·吾衰矣》"] +["金波银汉,潋滟无际。","柳永《佳人醉·暮景萧萧雨霁》"] +["只愿君心似我心,定不负相思意。","李之仪《卜算子·我住长江头》"] +["更问尊前狂副使。","苏轼《定风波·两两轻红半晕腮》"] +["仰视百鸟飞,大小必双翔。","杜甫《新婚别》"] +["阆州城东灵山白,阆州城北玉台碧。","杜甫《阆山歌》"] +["春风十里扬州路,卷上珠帘总不如。","杜牧《赠别》"] +["开鱼得锦字,归问我何如。","李白《秋浦寄内》"] +["尔德不明,以无陪无卿。","佚名《荡》"] +["之子归穷泉,重壤永幽隔。","潘安《悼亡诗三首》"] +["妻孥怪我在,惊定还拭泪。","杜甫《羌村》"] +["天明独去无道路,出入高下穷烟霏。","韩愈《山石》"] +["若无青嶂月,愁杀白头人。","杜甫《月三首》"] +["余拏一小舟,拥毳衣炉火,独往湖心亭看雪。","张岱《湖心亭看雪》"] +["一上玉关道,天涯去不归。","李白《王昭君二首》"] +["谁知林栖者,闻风坐相悦。","张九龄《感遇十二首》"] +["凌晨过骊山,御榻在嵽嵲。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["丹灶初开火,仙桃正落花。","孟浩然《清明日宴梅道士房 / 宴梅道士山房》"] +["梦里分明见关塞,不知何路向金微。","张仲素《秋闺思二首》"] +["低云愁广隰,落日惨重关。","徐惠《秋风函谷应诏》"] +["织锦相思楼影下,钿钗暗约小帘间。","吴文英《新雁过妆楼·中秋后一夕李方庵月庭延客命小妓过新水令坐间赋词》"] +["远芳侵古道,晴翠接荒城。","白居易《草 / 赋得古原草送别》"] +["春风豆蔻知多少,并作秋江一段愁。","《阅微草堂笔记·卷一·滦阳消夏录一(1)》"] +["击鼓其镗,踊跃用兵。","佚名《击鼓》"] +["山暝闻猿愁,沧江急夜流。","孟浩然《宿桐庐江寄广陵旧游》"] +["铁枢铁楗重束关,大旗五丈撞双环。","李贺《公莫舞歌》"] +["故园松桂发,万里共清辉。","杜甫《月圆》"] +["细蕊缀纷纷,淡粉轻脂最可人。","顾太清《南乡子·咏瑞香》"] +["莲粉飘红,菱丝翳碧,仰见明星空烂。","纳兰性德《鹊桥仙·乞巧楼空》"] +["夏之日,冬之夜。","佚名《葛生》"] +["仔细看山山不动,是船行。","佚名《浣溪沙·五两竿头风欲平》"] +["贞元供奉梨园曲,称十香、深蘸琼钟。","吴文英《风入松·麓翁园堂宴客》"] +["繁华事散逐香尘,流水无情草自春。","杜牧《金谷园》"] +["看渊明、风流酷似,卧龙诸葛。","辛弃疾《贺新郎·把酒长亭说》"] +["一弹再三叹,慷慨有余哀。","佚名《西北有高楼》"] +["寒夜客来茶当酒,竹炉汤沸火初红。","杜耒《寒夜》"] +["新书报我添憔悴。","苏轼《蝶恋花·昨夜秋风来万里》"] +["胡转予于恤,靡所止居?","佚名《祈父》"] +["君子至止,福禄既同。","佚名《瞻彼洛矣》"] +["嘲辞斗诡辩,活火分新茶。","李清照《晓梦》"] +["报答春光知有处,应须美酒送生涯。","杜甫《江畔独步寻花七绝句》"] +["寸恨谁云短,绵绵岂易裁。","苏轼《南歌子·感旧》"] +["芳春平仲绿,清夜子规啼。","沈佺期《夜宿七盘岭》"] +["胡儿敢作千年计,天意宁知一日回。","陆游《闻武均州报已复西京》"] +["想佩环、月夜归来,化作此花幽独。","姜夔《疏影·苔枝缀玉》"] +["报扫荡、狐嗥兔舞。","吴潜《海棠春·已未清明对海棠有赋》"] +["侬今葬花人笑痴,他年葬侬知有谁?","曹雪芹《葬花吟》"] +["良马不回鞍,轻车不转毂。","秦嘉《赠妇诗三首》"] +["有怀于卫,靡日不思。","佚名《泉水》"] +["野旷吕蒙营,江深刘备城。","杜甫《公安县怀古》"] +["忧勤是美德,太苦则无以适性怡情;淡泊是高风,太枯则无以济人利物。","《菜根谭·概论》"] +["月殿影开闻夜漏,水晶帘卷近秋河。","顾况《宫词五首·其二》"] +["关亭试一望,吾欲泪沾臆。","高适《杂曲歌辞·蓟门行五首》"] +["楼上春山寒四面,过尽征鸿,暮景烟深浅。","冯延巳《鹊踏枝·梅落繁枝千万片》"] +["最爱东山晴后雪,却愁宜看不宜登。","杨万里《雪后晚晴四山皆青惟东山全白赋最爱东山晴后雪二绝句》"] +["峨眉高出西极天,罗浮直与南溟连。","李白《当涂赵炎少府粉图山水歌》"] +["已抱沈痼疾,更贻魑魅忧。","孟浩然《送王昌龄之岭南》"] +["本自无教训,兼愧贵家子。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["笃公刘,于京斯依。","佚名《公刘》"] +["客槎曾犯,银河波浪,尚带天风海雨。","苏轼《鹊桥仙·七夕》"] +["庶氏有嘉草,攻襘事久泯。","柳宗元《种白蘘荷》"] +["明镜本清净,何处染尘埃!","惠能《菩提偈》"] +["勿云不相送,心到青门东。","白居易《别元九后咏所怀》"] +["楼头残梦五更钟,花底离情三月雨。","晏殊《玉楼春·春恨》"] +["高树月初白,微风酒半醒。","林景熙《溪亭》"] +["水榭风微玉枕凉。牙床角簟藕花香。","苏庠《浣溪沙·书虞元翁书》"] +["不知何处吹芦管,一夜征人尽望乡。","李益《夜上受降城闻笛》"] +["鬼伯一何相催促,人命不得稍踟蹰。","佚名《蒿里》"] +["吕望尚不希,夷齐何足慕。","孔融《杂诗二首》"] +["郁郁涧底松,离离山上苗。","左思《咏史·郁郁涧底松》"] +["人之无良,我以为君!","佚名《鹑之奔奔》"] +["以虚养心,以德养身,以仁养天下万物,以道养天下万世。","《格言联璧·存养类》"] +["非因斜日无由见,不是闲人岂得知。","白居易《晚桃花》"] +["却自移家向酒泉。","辛弃疾《丑奴儿·近来愁似天来大》"] +["秋日登滑台,台高秋已暮。","高适《自淇涉黄河途中作十三首》"] +["采菱辛苦废犁鉏,血指流丹鬼质枯。","范成大《夏日田园杂兴》"] +["凡与敌战,若我胜彼负,不可骄惰,当日夜严备以待之。","《百战奇略·第六卷·胜战》"] +["入门望爱子,妻妾向人悲。","孔融《杂诗二首》"] +["深秋寒夜银河静,月明深院中庭。","尹鹗《临江仙·深秋寒夜银河静》"] +["取彼谮人,投畀豺虎。","佚名《巷伯》"] +["慎尔优游,勉尔遁思。","佚名《白驹》"] +["新柳树,旧沙洲,去年溪打那边流。","辛弃疾《鹧鸪天·戏题村舍》"] +["中夜四五叹,常为大国忧。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["因学扬子云,献赋甘泉宫。","李白《东武吟》"] +["新诗如玉屑,出语便清警。","苏轼《送参寥师》"] +["忆梅下西洲,折梅寄江北。","佚名《西洲曲》"] +["王道已沦昧,战国竞贪兵。","陈子昂《蓟丘览古赠卢居士藏用七首》"] +["小阁重帘有燕过。","晏殊《浣溪沙·小阁重帘有燕过》"] +["律回岁晚冰霜少,春到人间草木知。","张栻《立春偶成》"] +["飘摇不可寄,徙倚徒相思。","徐干《室思》"] +["早岁那知世事艰,中原北望气如山。","陆游《书愤五首·其一》"] +["小山拄颐愁无奈,又怕声声聒夜眠。","择璘《咏杜鹃花》"] +["攀折争捷径,及此春风暄。","李白《咏桂》"] +["连歌数阕,皆清真词。","吴文英《惜黄花慢·送客吴皋》"] +["树木丛生,百草丰茂。","曹操《观沧海》"] +["文王曰咨,咨汝殷商。","佚名《荡》"] +["人间梦隔西风,算天上、年华一瞬。","吴文英《惜秋华·七夕》"] +["雄剑挂壁,时时龙鸣。","李白《独漉篇》"] +["觉客程劳,年光晚。","柳永《迷神引·一叶扁舟轻帆卷》"] +["闻君陇西行,使我惊心魂。","李白《赠别从甥高五》"] +["天地之大也,人犹有所憾。故君子语大,天下莫能载焉,语小,天下莫能破焉。","《中庸·第十二章》"] +["怨别自惊千里外,论交却忆十年时。","高适《东平别前卫县李寀少府 / 送前卫县李宷少府》"] +["心底湖头,路断到如今。","张惠言《江城子·填张春溪西湖竹枝词》"] +["弟子韩干早入室,亦能画马穷殊相。","杜甫《丹青引赠曹将军霸》"] +["手捻红笺寄人书,写无限伤春事。","晏几道《留春令》"] +["治强生于法,弱乱生于阿。","《韩非子·外储说右下》"] +["龙蟠虎踞金陵郡,古来六代豪华盛。","康与之《菩萨蛮令·金陵怀古》"] +["怜他飘泊奈他飞。","陈子龙《浣溪沙·杨花》"] +["周原膴々,堇荼如饴。","佚名《绵》"] +["饮至告言旋,功归清庙前。","杨广《饮马长城窟行》"] +["已有空乐好,况闻青猿哀。","李白《寻山僧不遇作》"] +["凡花瘦不禁秋,幻腻玉、腴红鲜丽。","吴文英《惜秋华·木芙蓉》"] +["明珠交玉体,珊瑚间木难。","曹植《美女篇》"] +["庭槐寒影疏,邻杵夜声急。","孟浩然《秋宵月下有怀》"] +["繁饰有命,以教众愚朴之人。","《墨子·36章 非命(中)》"] +["寒月悲笳,万里西风瀚海沙。","纳兰性德《采桑子·塞上咏雪花》"] +["乱烟笼碧砌,飞月向南端。","王勃《江亭夜月送别二首》"] +["蜀弦秦柱不关情,尽日掩云屏。","纳兰性德《朝中措·蜀弦秦柱不关情》"] +["一夕轻雷落万丝,霁光浮瓦碧参差。","秦观《春日》"] +["三寿作朋,如冈如陵。","佚名《鲁颂·閟宫》"] +["十步两躩跃,三呼一交兵。","李白《东海有勇妇》"] +["月色入高楼,相思两处愁。","秋瑾《菩萨蛮·寄女伴》"] +["一夜雨声凉到梦,万荷叶上送秋来。","陈文述《夏日杂诗》"] +["酒酣胸胆尚开张,鬓微霜,又何妨!","苏轼《江城子·密州出猎》"] +["独上小楼春欲暮,愁望玉关芳草路。","韦庄《木兰花·独上小楼春欲暮》"] +["元嘉草草,封狼居胥,赢得仓皇北顾。","辛弃疾《永遇乐·京口北固亭怀古》"] +["箭利弓调四镇兵,蕃人不敢近东行。","姚合《穷边词二首》"] +["鸿雁于飞,哀鸣嗷嗷。","佚名《鸿雁》"] +["皇灵无私亲,为善荷天禄。","秦嘉《赠妇诗三首》"] +["薄暮临征马,失道北山阿。","王褒2《渡河北》"] +["任炎天冰海,一杯相属。","范成大《满江红·清江风帆甚快作此与客剧饮歌之》"] +["忆来何事最销魂,第一折枝花样画罗裙。","纳兰性德《虞美人·曲阑深处重相见》"] +["翠幕深庭,露红晚、闲花自发。","吴文英《满江红·翠幕深庭》"] +["虎竹救边急,戎车森已行。","李白《出自蓟北门行》"] +["老大逢春,情绪有谁知?","王炎《江城子·癸酉春社》"] +["静胜躁,寒胜热。清静為天下正。","《老子·德经·第四十五章》"] +["其马蹻蹻,其音昭昭。","佚名《鲁颂·泮水》"] +["远戍唯闻鼓,寒山但见松。","阴铿《晚出新亭》"] +["横江西望阻西秦,汉水东连扬子津。","李白《横江词六首》"] +["人之聪明,多失于浮炫","《金史·列传·卷三十三》"] +["功名炊黍寻常梦,怪事书空感激人。","王逊《西夏重阳》"] +["浮醪随觞转,素蚁自跳波。","张华《轻薄篇》"] +["惠而好我,携手同归。","佚名《北风》"] +["祈年孔夙,方社不莫。","佚名《云汉》"] +["人悄悄,月依依,翠帘垂。","李清照《诉衷情·夜来沉醉卸妆迟》"] +["余乃欷歔怅望,泣涕彷徨。","曹雪芹《芙蓉女儿诔》"] +["石鼓之歌止于此,呜呼吾意其蹉跎。","韩愈《石鼓歌》"] +["寄卧郊扉久,何年致此身。","马戴《灞上秋居》"] +["槛内群芳芽未吐,早已回春。","苏轼《浪淘沙·探春》"] +["楚楚者茨,言抽其棘。","佚名《楚茨》"] +["此生飘荡何时歇?","苏轼《醉落魄·离京口作》"] +["不须更上新亭望,大不如前洒泪时。","虞集《挽文丞相》"] +["慨当以慷,忧思难忘。","曹操《短歌行》"] +["丛薄聚冻禽,狐狸啸枯株。","王冕《冀州道中》"] +["笛奏梅花曲,刀开明月环。","李白《从军行》"] +["乡国真堪恋,光阴可合轻。","白居易《洛桥寒食日作十韵》"] +["日居月诸,照临下土。","佚名《日月》"] +["别时酒犹在,已为异乡客。","李白《江行寄远》"] +["朝回日日典春衣,每日江头尽醉归。","杜甫《曲江二首》"] +["金舟不能凌阳侯之波,玉马不任骋千里之迹也。","《抱朴子·外篇·用刑》"] +["居逆境中,周身皆针砭药石,砥节砺行而不觉;处顺境内,满前尽兵刃戈矛,销膏靡骨而不知。","《菜根谭·概论》"] +["乍咽凉柯,还移暗叶,重把离愁深诉。","王沂孙《齐天乐·蝉》"] +["东风力,快将云雁高送。","吴文英《宴清都·送马林屋赴南宫分韵得动字》"] +["朝曦迎客艳重冈,晚雨留人入醉乡。","苏轼《饮湖上初晴后雨二首·其一》"] +["安详是处事第一法,谦退是保身第一法,涵容是处人第一法,洒脱是养心第一法。","《小窗幽记·集醒篇》"] +["惟仁者宜在高位。不仁而在高位,是播其恶于众也。","《孟子·离娄章句上·第一节》"] +["巧笑倩兮,美目盼兮,素以为绚兮。","《论语·八佾篇》"] +["瞻彼阪田,有菀其特。","佚名《正月》"] +["项王气盖世,紫电明双瞳。","李白《登广武古战场怀古》"] +["今宵好风月,阿侯在何处。","李贺《绿水词》"] +["抚衿长叹息,不觉涕沾胸。","潘安《悼亡诗三首》"] +["楼前流水江陵道,鲤鱼风起芙蓉老。","李贺《江楼曲》"] +["采幽香,巡古苑,竹冷翠微路。","吴文英《祝英台近·春日客龟溪游废园》"] +["世间荣落重逡巡,我独丘园坐四春。","李商隐《春日寄怀》"] +["步屟随春风,村村自花柳。","杜甫《遭田父泥饮美严中丞》"] +["今皇神圣丞相明,诏书才下吴蜀平。","元稹《连昌宫词》"] +["天涯、还忆旧,香尘随马,明月窥车。","王国维《满庭芳·水抱孤城》"] +["借曰未知,亦既抱子。","佚名《抑》"] +["雨滴长门秋夜长,愁心和雨到昭阳。","刘皂《长门怨三首》"] +["径出梅花桥,双溪纳归潮。","李白《送王屋山人魏万还王屋·并序》"] +["心似百花开未得,年年争发被春催。","曹松《南海旅次》"] +["眼边江舸何匆促,未待安流逆浪归。","杜甫《雨不绝》"] +["天公出美酒,河伯出鲤鱼。","佚名《艳歌》"] +["起来搔首,梅影横窗瘦。","汪藻《点绛唇·新月娟娟》"] +["尽捻书籍卖,来问尔东家。","杜甫《陪郑广文游何将军山林》"] +["尾尾相衔画舫,尽欢声无日不笙簧。","奥敦周卿《蟾宫曲·咏西湖》"] +["啼得花残声更悲,叫得春归郎未知。","王元鼎《凭栏人·闺怨》"] +["爱子心无尽,归家喜及辰。","蒋士铨《岁暮到家》"] +["夜半来,天明去。","白居易《花非花》"] +["料黛眉重锁隋堤,芳心还动梁苑。","卢祖皋《宴清都·初春》"] +["当时结交何纷纷,片言道合惟有君。","李白《驾去温泉后赠杨山人》"] +["经行仿佛近诸天,月上山衔半缺圆。","常慧《云居山咏二首》"] +["今日明光里,还须结伴游。","李白《宫中行乐词八首》"] +["晴风初破冻,柳眼梅腮,已觉春心动。","李清照《蝶恋花·暖雨晴风初破冻》"] +["牵迫限修途,未暇杖崇冈。","杜甫《望岳三首·其三》"] +["年年耕种宫前道,今年不遣子孙耕。","元稹《连昌宫词》"] +["岁云暮矣多北风,潇湘洞庭白雪中。","杜甫《岁晏行》"] +["路断车轮生四角,此地行人销骨。","辛弃疾《贺新郎·把酒长亭说》"] +["斗万花样巧,深染蜂黄。","吴文英《惜黄花慢·菊》"] +["古来得意不相负,只今惟见青陵台。","李白《白头吟》"] +["天下有道则见,无道则隐。","《论语·泰伯篇》"] +["独怜幽草涧边生,上有黄鹂深树鸣。","韦应物《滁州西涧》"] +["旧时王谢堂前燕,飞入寻常百姓家。","刘禹锡《乌衣巷》"] +["凡德者,以无为集,以无欲成,以不思安,以不用固。","《韩非子·解老》"] +["低头羞见人,双手结裙带。","毛铉《幼女词·下床着新衣》"] +["春窗曙灭九微火,九微片片飞花琐。","王维《洛阳女儿行》"] +["善之生如春,恶之死如秋,故民劝极力而乐尽情,此之谓上下相得。","《韩非子·守道》"] +["当时领略,而今断送,总负多情。","纳兰性德《青衫湿·悼亡》"] +["尚记当日,绿阴门掩。","詹玉《长亭怨慢·重过中庵故园》"] +["请为父老歌,艰难愧深情。","杜甫《羌村》"] +["云物凄凉拂曙流,汉家宫阙动高秋。","赵嘏《长安晚秋 / 秋望 / 秋夕》"] +["子治世之能臣,乱世之奸雄也。","《三国演义·第一回》"] +["明朝拂衣去,永与海鸥群。","李白《赠王判官时余归隐居庐山屏风叠》"] +["营州少年厌原野,狐裘蒙茸猎城下。","高适《营州歌》"] +["空自觉、围羞带减,影怯灯孤。","张炎《渡江云·山阴久客一再逢春回忆西杭渺然愁思》"] +["更被鹭鹚千点雪,破烟来入画屏飞。","韦庄《稻田》"] +["君子泰而不骄,小人骄而不泰。","《论语·子路篇》"] +["王奋厥武,如震如怒。","佚名《大雅·常武》"] +["但将痛饮酬风月,莫放离歌入管弦。","辛弃疾《鹧鸪天·离豫章别司马汉章大监》"] +["回头流水小桥东,烟扫画楼出。","雷应春《好事近·梅片作团飞》"] +["云带雨,浪迎风,钓翁回棹碧湾中。","李珣《南乡子·云带雨》"] +["王在在镐,有那其居。","佚名《鱼藻》"] +["便挽取、长江入尊罍,浇胸臆。","赵鼎《满江红·丁未九月南渡泊舟仪真江口作》"] +["当时坐间英俊,强半已凋亡。","蔡伸《水调歌头·亭皋木叶下》"] +["四牡有骄,朱幩镳镳。","佚名《硕人》"] +["时自笑,虚名负我,半生吟啸。","元好问《玉漏迟·咏杯》"] +["圣人之救危国也,以忠拂耳。","《韩非子·安危》"] +["败不可处,时不可失,忠不可弃,怀不可从","《国语·晋语·齐姜劝重耳勿怀安》"] +["几岁开花闻喷雪,何人摘实见垂珠?","柳宗元《柳州城西北隅种柑树》"] +["穷且益坚,不坠青云之志。","王勃《滕王阁序》"] +["塞上秋风鼓角,城头落日旌旗。少年鞍马适相宜。","元好问《江月晃重山·初到嵩山时作》"] +["十分不耐烦,乃为人大病","《围炉夜话·第五四则》"] +["长松之下列羽客,对坐不语南昌仙。","李白《当涂赵炎少府粉图山水歌》"] +["山舍墓田同水曲,不堪梦觉听啼鹃。","虞集《至正改元辛巳寒食日示弟及诸子侄》"] +["追念西湖上,小舫携歌,晚花行乐。","姜夔《凄凉犯·绿杨巷陌秋风起》"] +["遨欢任落风前帽,促饮争吹酒上花。","宋祁《九日置酒》"] +["云澹星疏楚山晓。","吴文英《夜游宫·人去西楼雁杳》"] +["鱼没浪痕圆,流红去,翻笑东风难扫。","张炎《南浦·春水》"] +["机心久已忘,何事惊麋鹿。","柳宗元《秋晓行南谷经荒村》"] +["万里不足步,轻举凌太虚。","曹植《仙人篇》"] +["两水夹明镜,双桥落彩虹。","李白《秋登宣城谢脁北楼》"] +["吴生虽妙绝,犹以画工论。","苏轼《王维吴道子画》"] +["吏怒反索金,黎民那有此。","钱嶫《悯黎咏》"] +["昔之善战者,先为不可胜,以待敌之可胜。不可胜在己,可胜在敌。","《孙子兵法·军形篇》"] +["几多惆怅,情绪在天涯!","顾敻《临江仙·月色穿帘风入竹》"] +["叔善射忌,又良御忌。","佚名《大叔于田》"] +["不戚戚于贫贱,不汲汲于富贵。","陶渊明《五柳先生传》"] +["观者皆嘘唏,行路亦呜咽。","蔡琰《悲愤诗》"] +["客醉倚河桥,清光愁玉箫。","高观国《菩萨蛮·何须急管吹云暝》"] +["石头城上,望天低吴楚,眼空无物。","萨都剌《念奴娇·登石头城次东坡韵》"] +["老夫灌灌,小子蹻々。","佚名《板》"] +["南阜小亭台,薄有山花取次开。","虞集《南乡一剪梅·招熊少府》"] +["黄阁紫枢,筑坛开府。","陆游《感皇恩·小阁倚秋空》"] +["戒我师旅,率彼淮浦,省此徐土。","佚名《大雅·常武》"] +["见说征夫容易瘦,端相。","纳兰性德《南乡子·捣衣》"] +["饮至告言旋,功归清庙前。","杨广《饮马长城窟行》"] +["天下未有不学而成者也。","《中说·卷六·礼乐篇》"] +["不信狂涛东驶,蛟龙偶语分明。","朱孝臧《清平乐·夜发香港》"] +["知子之来之,杂佩以赠之。","佚名《女曰鸡鸣》"] +["何必长从七贵游,劳生徒聚万金产。","李白《下途归石门旧居》"] +["啜苦咽甘,茶也。","《茶经·五之煮》"] +["暗暗淡淡紫,融融冶冶黄。","李商隐《菊花》"] +["小窗愁黛淡秋山。","吴文英《鹧鸪天·化度寺作》"] +["杨柳散和风,青山澹吾虑。","韦应物《东郊》"] +["直至如今千载后,谁与争功!","王安石《浪淘沙令·伊吕两衰翁》"] +["长衢罗夹巷,王侯多第宅。","佚名《青青陵上柏》"] +["木欣欣以向荣,泉涓涓而始流。","陶渊明《归去来兮辞·并序》"] +["即此是高唐,掩屏秋梦长。","孙光宪《菩萨蛮·月华如水笼香砌》"] +["丛萼中竞秀,分房处舒英。","柳宗元《植灵寿木》"] +["王侯将相宁有种乎?","《史记·陈涉世家》"] +["澹月秋千,幽香巷陌,愁结伤春深处。","吴文英《齐天乐·新烟初试花如梦》"] +["丽谯吹罢小单于。","秦观《阮郎归·湘天风雨破寒初》"] +["长门事,准拟佳期又误。","辛弃疾《摸鱼儿·更能消几番风雨》"] +["万水归阴,故潮信盈虚因月。","史达祖《满江红·中秋夜潮》"] +["输于池上鸳鸯,日日阑前双暝。","张惠言《双双燕·满城社雨》"] +["一向发娇嗔,碎挼花打人。","佚名《菩萨蛮·牡丹含露真珠颗》"] +["十年前是尊前客,月白风清,忧患凋零。","欧阳修《采桑子·十年前是尊前客》"] +["今年摧颓最堪笑,华发苍颜羞自照。","陆游《三月十七日夜醉中作》"] +["君知五蕴皆是贼,人生一病今先差。","苏轼《次韵秦太虚见戏耳聋》"] +["有车邻邻,有马白颠。","佚名《车邻》"] +["观今宜鉴古,无古不成今。","《增广贤文·上集》"] +["攘之剔之,其檿其柘。","佚名《皇矣》"] +["戎马不如归马逸, 千家今有百家存。","杜甫《白帝》"] +["五月江吴麦秀寒,移秧披絮尚衣单。","范成大《夏日田园杂兴》"] +["平生惯写龙凤质,肯顾草间猿与獐。","苏轼《赠写御容妙善师》"] +["画帘开、束衣纨扇,午风清暑。","刘克庄《贺新郎·端午》"] +["四郊未宁静,垂老不得安。","杜甫《垂老别》"] +["今年八月十五夜,湓浦沙头水馆前。","白居易《八月十五日夜湓亭望月》"] +["欲谱频年离恨,言已尽、恨未曾消。","纳兰性德《东风齐着力·电急流光》"] +["敛兵卫、阊阖门开,住传宣、又还休务。","万俟咏《三台·清明应制》"] +["念彼共人,涕零如雨。","佚名《小明》"] +["隐居寺,隐居山,陶公炼液栖其间。","李白《下途归石门旧居》"] +["软衬飞花,远连流水,一望隔香尘。","高观国《少年游·草》"] +["山公醉酒时,酩酊高阳下。","李白《襄阳曲四首》"] +["凭谁为歌长恨?暗殿锁、秋灯夜语。","吴文英《宴清都·连理海棠》"] +["后三十余年,翁既捐馆舍,遵亦没久矣。","苏轼《醉翁操·琅然》"] +["斜插犀梳云半吐,檀板轻敲,唱彻黄金缕。","司马槱《黄金缕·妾本钱塘江上住》"] +["梦里轻螺谁扫,帘外落花红小。","纳兰性德《谒金门·风丝袅》"] +["率履不越,遂视既发。","佚名《商颂·长发》"] +["山中何所有,岭上多白云。","陶弘景《诏问山中何所有赋诗以答》"] +["橐驼何连连,穹帐亦累累。","岑参《北庭西郊候封大夫受降回军献上》"] +["织妇何太忙,蚕经三卧行欲老。","元稹《织妇词》"] +["神释大钧无私力,万理自森著。","陶渊明《形影神三首》"] +["一自绿窗偷见后,便憔悴、到如今。","苏轼《江城子·腻红匀脸衬檀唇》"] +["十步杀一人,千里不留行。","李白《侠客行》"] +["水眄兰情,总平生稀见。","周邦彦《拜星月·高平秋思》"] +["漫待客黄楼,尘波前度。","刘辰翁《桂枝香·吹箫人去》"] +["背夏浦而西思兮,哀故都之日远。","屈原《哀郢》"] +["深禁好春谁惜,薄暮瑶阶伫立。","纳兰性德《昭君怨·深禁好春谁惜》"] +["尾尾相衔画舫,尽欢声无日不笙簧。","奥敦周卿《蟾宫曲·咏西湖》"] +["孰云网恢恢,将老身反累。","杜甫《梦李白二首·其二》"] +["攀龙附凤势莫当,天下尽化为侯王。","杜甫《洗兵马》"] +["心期切处,更有多少凄凉,殷勤留与归时说。","张元干《石州慢·寒水依痕》"] +["遍胪欢,腾天歌。","刘彻《华晔晔》"] +["翦红情,裁绿意,花信上钗股。","吴文英《祝英台近·除夜立春》"] +["唯有旧家秋娘,声价如故。","周邦彦《瑞龙吟·大石春景》"] +["谁向江头遗恨浓,碧波流不断,楚山重。","祖可《小重山·谁向江头遗恨浓》"] +["奄奄黄昏后,寂寂人定初。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["新声含尽古今情。","秦观《临江仙·千里潇湘挼蓝浦》"] +["舞阳死灰人,安可与成功。","李白《结客少年场行》"] +["长安如梦里,何日是归期。","李白《送陆判官往琵琶峡》"] +["校尉羽书飞瀚海,单于猎火照狼山。","高适《燕歌行》"] +["情人怨遥夜,竟夕起相思。","张九龄《望月怀远》"] +["俾予靖之,后予极焉。","佚名《菀柳》"] +["毕竟不成眠,一夜长如岁。","柳永《忆帝京·薄衾小枕凉天气》"] +["玉勒雕鞍游冶处,楼高不见章台路。","欧阳修《蝶恋花·庭院深深深几许》"] +["空向梁间觅宿痕。","李之仪《南乡子·端午》"] +["沧海桑田,谓世事之多变;河清海晏,兆天下之升平。","《幼学琼林·卷一·地舆》"] +["柳庭风静人眠昼,昼眠人静风庭柳。","苏轼《菩萨蛮·回文夏闺怨》"] +["故不登高山,不知天之高也;不临深溪,不知地之厚也;","荀子《劝学》"] +["事君数,斯辱矣;朋友数,斯疏矣。","《论语·里仁篇》"] +["愿为双黄鹄,高飞还故乡。","佚名《步出城东门》"] +["多君相门女,学道爱神仙。","李白《送内寻庐山女道士李腾空二首》"] +["以近知远,以一知万,以微知明,此之谓也。","《荀子·非相》"] +["楚天晚,白苹烟尽处,红蓼水边头。","张耒《风流子·木叶亭皋下》"] +["依依宜织江雨空,雨中六月兰台风。","李贺《罗浮山父与葛篇》"] +["泰山嵯峨夏云在,疑是白波涨东海。","李白《早秋单父南楼酬窦公衡》"] +["低徊愧人子,不敢叹风尘。","蒋士铨《岁暮到家 / 岁末到家》"] +["湘江千岁未为陵,水底鱼龙应识字。","鲍溶《襄阳怀古》"] +["瞻彼淇奥,绿竹青青。","佚名《淇奥》"] +["小山拄颐愁无奈,又怕声声聒夜眠。","择璘《咏杜鹃花》"] +["倚云低首望,可知心?","鹿虔扆《女冠子·凤楼琪树》"] +["慨念平生豪放,自笑如今霜鬓,漂泊水云乡。","蔡伸《水调歌头·亭皋木叶下》"] +["客舟何处来,棹歌中流声抑扬。","苏轼《李思训画长江绝岛图》"] +["丧虽有礼,而哀为本焉。","《墨子·02章 修身》"] +["芳原绿野恣行事,春入遥山碧四围。","程颢《郊行即事》"] +["不据山河据平地,长戈利矛日可麾。","李商隐《韩碑》"] +["桑条无叶土生烟,箫管迎龙水庙前。","李约《观祈雨》"] +["朱舰红船早满湖。","苏轼《瑞鹧鸪·城头月落尚啼乌》"] +["举秀才,不知书。","佚名《桓灵时童谣》"] +["下贫则上贫,下富则上富。","《荀子·富国》"] +["翘思慕远人,愿欲托遗音。","曹植《杂诗六首·其一》"] +["军书发上郡,春色度河阳。","李颀《古塞下曲》"] +["疑怪昨宵春梦好,元是今朝斗草赢。","晏殊《破阵子·春景》"] +["迢迢牵牛星,皎皎河汉女。","佚名《迢迢牵牛星》"] +["对望中天地,洞然如刷。","史达祖《满江红·中秋夜潮》"] +["独自立瑶阶,透寒金缕鞋。","纳兰性德《菩萨蛮·隔花才歇廉纤雨》"] +["壮岁旌旗拥万夫,锦襜突骑渡江初。","辛弃疾《鹧鸪天·有客慨然谈功名因追念少年时事戏作》"] +["红色雨和烟,行人江那边。","沈蔚《菩萨蛮·落花迤逦层阴少》"] +["惟仁者宜在高位。","《孟子·离娄章句上·第一节》"] +["搴帷拜母河梁去,白发愁看泪眼枯。","黄景仁《别老母》"] +["麟之角,振振公族,于嗟麟兮。","佚名《麟之趾》"] +["骂贼睢阳,爱君许远,留取声名万古香。","文天祥《沁园春·题潮阳张许二公庙》"] +["山有枢,隰有榆。","佚名《山有枢》"] +["谈兵究弊又何益,万口不谓儒者知。","梅尧臣《醉中留别永叔子履》"] +["独立苍茫醉不归。","辛弃疾《一剪梅·游蒋山呈叶丞相》"] +["异乡风物,忍萧索、当愁眼。","柳永《迷神引·一叶扁舟轻帆卷》"] +["九十光阴能有几?金龟解尽留无计。","朱服《渔家傲·小雨纤纤风细细》"] +["连呼五白行六博,分曹赌酒酣驰晖。","李白《梁园吟》"] +["入牖千重碎,迎风一半斜。","李世民《望雪》"] +["使臣将王命,岂不如贼焉?","元结《贼退示官吏》"] +["之子合逸趣,而我钦清芬。","李白《题元丹丘颍阳山居》"] +["蜂蝶不知春已去,又衔花瓣到兰房。","智生《夏词》"] +["进身齐选择,失路同瑕疵。","柳宗元《哭连州凌员外司马》"] +["行云归楚峡,飞梦到扬州。","晁补之《临江仙·绿暗汀洲三月暮》"] +["民之难治,以其智多。故以智治国,国之贼;不以智治国,国之福。","《老子·德经·第六十五章》"] +["莫厌尊前醉,相看未白首。","柳宗元《法华寺西亭夜饮》"] +["莺归燕去长悄然,春往秋来不记年。","白居易《上阳白发人》"] +["执政方持法,明君照此心。","王维《初出济州别城中故人》"] +["使君未出郡斋外,江上早闻齐和声。","张建封《竞渡歌》"] +["两行柳垂阴,是当日、仙翁手植。","姜夔《蓦山溪·题钱氏溪月》"] +["秋鬓改,妒月姊、长眉妩。","吴文英《荔枝香近·七夕》"] +["见尽人间妇,无如美且贤。","梅尧臣《悼亡三首》"] +["谁问旗亭,美酒斗十千。","贺铸《行路难·缚虎手》"] +["秦中花鸟已应阑,塞外风沙犹自寒。","王翰《凉州词二首》"] +["与天一样白漫漫。","章谦亨《浪淘沙·云藏鹅湖山》"] +["天长水阔厌远涉,访古始及平台间。","李白《梁园吟》"] +["每出深宫里,常随步辇归。","李白《宫中行乐词八首》"] +["御柳如丝映九重,凤凰窗映绣芙蓉。","温庭筠《杨柳枝》"] +["蛾眉淡了教谁画?瘦岩岩羞戴石榴花。","关汉卿《大德歌·夏》"] +["但满目京尘,东风竟日吹露桃。","周邦彦《忆旧游·记愁横浅黛》"] +["想恁时,小几银屏冷未了。","施岳《解语花·云容冱雪》"] +["车声渐共歌声咽。","王国维《蝶恋花·满地霜华浓似雪》"] +["当年悔草长杨赋。","陆游《蝶恋花·桐叶晨飘蛩夜语》"] +["儒者在本朝则美政,在下位则美俗。","《荀子·儒效》"] +["枕前泪共阶前雨,隔个窗儿滴到明。","聂胜琼《鹧鸪天·别情》"] +["终年会飘堕,安得久馨香。","宋子侯《董娇饶》"] +["六朝春草里,万井落花中。","屈大均《秣陵》"] +["乐殊贵贱,礼别尊卑。","《千字文·全文》"] +["民之贪乱,宁为荼毒。","佚名《桑柔》"] +["燕昭延郭隗,遂筑黄金台。","李白《古风·其十五》"] +["若教解爱繁华事,冻杀黄金屋里人。","来鹄《蚕妇》"] +["旧曲梅花唱,新正柏酒传。","孟浩然《岁除夜会乐城张少府宅》"] +["小时了了,大未必佳。","《世说新语·言语》"] +["又片片、吹尽也,几时见得?","姜夔《暗香·旧时月色》"] +["三冬暂就儒生学,千耦还从父老耕。","陆游《观村童戏溪上》"] +["草合人踪断,尘浓鸟迹深。","李白《谒老君庙》"] +["只座上、已无老兵。","姚云文《紫萸香慢·近重阳》"] +["粤余任推迁,三十犹未遇。","孟浩然《田园作》"] +["交语速装束,络绎如浮云。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["九衢雪小,千门月淡,元宵灯近。","晁端礼《水龙吟·咏月》"] +["嫁女择佳婿,毋索重聘;娶媳求淑女,勿计厚奁。","《朱子家训·全文》"] +["绣圈犹带脂香浅。","吴文英《踏莎行·润玉笼绡》"] +["酒醒梦回愁几许。","苏轼《谒金门·秋感》"] +["我今寻阳去,辞家千里馀。","李白《秋浦寄内》"] +["便步髯奴呼子履,又令开席罗酒卮。","梅尧臣《醉中留别永叔子履》"] +["十子若不肖,不如一女英。","李白《东海有勇妇》"] +["赳赳武夫,公侯腹心。","佚名《兔罝》"] +["露从今夜白,月是故乡明。","杜甫《月夜忆舍弟》"] +["宿昔青云志,蹉跎白发年。","张九龄《照镜见白发》"] +["两两归鸿欲破群,依依还似北归人。","苏轼《惠崇春江晚景二首 / 惠崇春江晓景二首》"] +["五岳起方寸,隐然讵可平。","李白《望鹦鹉洲怀祢衡》"] +["汉皇按剑起,还召李将军。","李白《塞下曲六首》"] +["汉家天子今神武,不肯和亲归去来。","王之涣《凉州词二首》"] +["床前明月光,疑是地上霜。","李白《静夜思》"] +["地即帝王宅,山为龙虎盘。","李白《金陵三首》"] +["民莫不穀,我独何害!","佚名《蓼莪》"] +["之子于钓,言纶之绳。","佚名《采绿》"] +["一曲当筵落泪,重掩罗巾。","晏殊《山亭柳·赠歌者》"] +["祸不好,不能为祸。","《国语·周语·太子晋谏灵王壅谷水》"] +["既见君子,云胡不喜。","佚名《风雨》"] +["绿杯红袖趁重阳。人情似故乡。","晏几道《阮郎归·天边金掌露成霜》"] +["此恨难平君知否,似琼台、涌起弹棋局。","蒋捷《贺新郎·梦冷黄金屋》"] +["碧玉妆成一树高,万条垂下绿丝绦。","贺知章《咏柳 / 柳枝词》"] +["欲去也、来无计。","程垓《酷相思·月挂霜林寒欲坠》"] +["鼓吹未容迎五马,水云先已漾双凫。","苏轼《瑞鹧鸪·城头月落尚啼乌》"] +["天德悠且长,人命一何促。","佚名《怨诗行》"] +["去来固无迹,动息如有情。","王勃《咏风》"] +["门外苍官千百辈,尽堂堂八尺须髯古。","辛弃疾《贺新郎·和徐斯远下第谢诸公载酒相访韵》"] +["溥彼韩城,燕师所完。","佚名《韩奕》"] +["花腮藏翠,高节穿花遮护。","曹勋《夹竹桃花·咏题》"] +["更被夕阳江岸上,断肠烟柳一丝丝。","韦庄《江外思乡》"] +["五月五日天晴明,杨花绕江啼晓莺。","张建封《竞渡歌》"] +["借问襄阳老,江山空蔡州。","王维《哭孟浩然》"] +["更遣黄龙戍,唯当哭塞云。","王昌龄《塞下曲四首》"] +["酣歌激壮士,可以摧妖氛。","李白《九日登巴陵置酒望洞庭水军》"] +["危桥属幽径,缭绕穿疏林。","柳宗元《巽公院五咏·苦竹桥》"] +["屈平憔悴滞江潭,亭伯流离放辽海。","李白《单父东楼秋夜送族弟沈之秦》"] +["一寸丹心图报国,两行清泪为思亲。","于谦《立春日感怀》"] +["老翁欹枕听莺啭,童子开门放燕飞。","范成大《晚春田园杂兴》"] +["青青河畔草,绵绵思远道。","佚名《饮马长城窟行》"] +["无数春笋满林生,柴门密掩断行人。","杜甫《咏春笋》"] +["杨柳东风树,青青夹御河。","王之涣《送别》"] +["天能生物,不能辨物也,地能载人,不能治人也;宇中万物生人之属,待圣人然后分也。","《荀子·礼论》"] +["一日不作诗,心源如废井。","贾岛《戏赠友人》"] +["飘然快拂花梢,翠尾分开红影。","史达祖《双双燕·咏燕》"] +["猛犬吠九关,杀人愤精魂。","李白《书情题蔡舍人雄》"] +["懊恼人心不如石,少时东去复西来。","刘禹锡《竹枝词九首》"] +["二年遍历楚山川。","辛弃疾《鹧鸪天·离豫章别司马汉章大监》"] +["故故坠金鞭,回头应眼穿。","牛峤《菩萨蛮·风帘燕舞莺啼柳》"] +["驿马从西来,双节夹路驰。","岑参《北庭西郊候封大夫受降回军献上》"] +["虚阁上,倚阑望,还似去年惆怅。","温庭筠《更漏子·星斗稀》"] +["停车数行日,劝酒问回期。","王建《送人游塞》"] +["月堕更阑,更请宫高奏独弹。","苏轼《减字木兰花·空床响琢》"] +["北斗酌美酒,劝龙各一觞。","李白《短歌行》"] +["且趁余花谋一笑。","欧阳修《蝶恋花·尝爱西湖春色早》"] +["浑未辨,黄柑荐酒,更传青韭堆盘?","辛弃疾《汉宫春·立春日》"] +["一去昆仑西,何时复回翔。","阮籍《咏怀八十二首·其七十九》"] +["岂曰无衣?六兮。","佚名《无衣》"] +["欲尽此情书尺素,浮雁沉鱼,终了无凭据。","晏几道《蝶恋花·梦入江南烟水路》"] +["漏断高城,钟疏野寺,遥送凉潮呜咽。","厉鹗《齐天乐·秋声馆赋秋声》"] +["南去北来徒自老,故人稀。","贺铸《钓船归·绿净春深好染衣》"] +["逢时独为贵,历代非无才。","陈子昂《蓟丘览古赠卢居士藏用七首》"] +["明珠交玉体,珊瑚间木难。","曹植《美女篇》"] +["青山隐隐水迢迢,秋尽江南草未凋。","杜牧《寄扬州韩绰判官》"] +["千秋佳节名空在,承露丝囊世已无。","杜牧《过勤政楼》"] +["惟有醉将醽醁,任他柔橹轻移。","董士锡《木兰花慢·武林归舟中作》"] +["窥鱼笑汝痴计,不解举吾杯。","辛弃疾《水调歌头·盟鸥》"] +["露凄暄风息,气澈天象明。","陶渊明《九日闲居》"] +["抑此皇父,岂曰不时?","佚名《十月之交》"] +["君负纵横才,如何尚憔悴?","高适《效古赠崔二》"] +["养心须淡泊,凡足以累吾心者勿为也。","《围炉夜话·第九八则》"] +["三川北虏乱如麻,四海南奔似永嘉。","李白《永王东巡歌十一首》"] +["高丘怀宋玉,访古一沾裳。","李白《宿巫山下》"] +["霜降碧天静,秋事促西风。","叶梦得《水调歌头·九月望日与客习射西园余偶病不能射》"] +["夸愚适增累,矜智道逾昏。","陈子昂《感遇诗三十八首·其十九》"] +["客路青山外,行舟绿水前。","王湾《次北固山下》"] +["辽东海北翦长鲸,风云万里清。","杨广《纪辽东二首》"] +["忍泪不能歌,试托哀弦语。","晏几道《生查子·坠雨已辞云》"] +["何时腾风云,搏击申所能?","李白《赠新平少年》"] +["世上功名,老来风味,春归时候。","晁补之《水龙吟·次歆林圣予惜春》"] +["抱素琴,独向银蟾影里,此怀难寄。","苏轼《水龙吟·小沟东接长江》"] +["试拂铁衣如雪色,聊持宝剑动星文。","王维《老将行》"] +["也应似旧,盈盈秋水,淡淡春山。","阮阅《眼儿媚·楼上黄昏杏花寒》"] +["仆御涕流离,辕马悲且鸣。","石崇《王明君》"] +["淋漓醉墨,看龙蛇飞落蛮笺。","陆游《汉宫春·初自南郑来成都作》"] +["薄暮空潭曲,安禅制毒龙。","王维《过香积寺》"] +["功勋多被黜,兵马亦寻分。","王昌龄《塞下曲四首》"] +["忽思剡溪去,水石远清妙。","李白《经乱后将避地剡中留赠崔宣城》"] +["若说没奇缘,今生偏又遇着他,若说有奇缘,如何心事终虚化?","《红楼梦·第五回》"] +["豪杰向简淡中求,神仙从忠孝上起。","《小窗幽记·集醒篇》"] +["云霞出海曙,梅柳渡江春。","杜审言《和晋陵陆丞早春游望》"] +["少陵诗思旧才名。","晏几道《临江仙·淡水三年欢意》"] +["驿路侵斜月,溪桥度晓霜。","吕本中《南歌子·驿路侵斜月》"] +["石城依旧空江国,故宫春色。","孙光宪《后庭花·石城依旧空江国》"] +["偶参伍之验,以责陈言之实。","《韩非子·备内》"] +["不对芳春酒,还望青山郭。","谢朓《游东田》"] +["但见文翁能化俗,焉知李广未封侯。","杜甫《将赴荆南寄别李剑州》"] +["从此忧来非一事,岂容华发待流年。","柳宗元《岭南江行》"] +["夜来处处试新妆。","刘辰翁《西江月·新秋写兴》"] +["鸬鹚山头微雨晴,扬州郭里暮潮生。","李颀《送刘昱》"] +["绿叶阴阴占得春,草满莺啼处。","徐俯《卜算子·天生百种愁》"] +["昔为匣中玉,今为粪上英。","石崇《王明君》"] +["翘思慕远人,愿欲托遗音。","曹植《杂诗六首·其一》"] +["望云五等舞万玉,献寿一声出千峰。","鲍防《元日早朝行》"] +["北风吹,能几时?","于谦《北风吹》"] +["桑下春蔬绿满畦,菘心青嫩芥苔肥。","范成大《春日田园杂兴》"] +["望天不尽,背城渐杳,离亭黯黯,恨永迢迢。","吴文英《惜黄花慢·送客吴皋》"] +["诚者自成也,而道自道也。","《中庸·第二十五章》"] +["只一事不留心,便有一事不得其理;只一物不留心,便有一物不得其所。","《格言联璧·处事类》"] +["鸿雁向西北,因书报天涯。","李白《千里思》"] +["倚竹不胜愁,暗想江头归路。","王之道《如梦令·一晌凝情无语》"] +["就中堪人属意,最是虫虫。","柳永《集贤宾·小楼深巷狂游遍》"] +["五将已深入,前军止半回。","高适《自蓟北归》"] +["银烛秋光冷画屏,轻罗小扇扑流萤。","杜牧《秋夕》"] +["门庭多落叶,慨然知已秋。","陶渊明《酬刘柴桑》"] +["受爵不让,至于已斯亡。","佚名《角弓》"] +["愔愔坊陌人家,定巢燕子,归来旧处。","周邦彦《瑞龙吟·大石春景》"] +["士马发燕赵,布帛来青徐。","陆游《观大散关图有感》"] +["不是花中偏爱菊,此花开尽更无花。","元稹《菊花》"] +["凡百君子,莫肯用讯。","佚名《雨无正》"] +["巴子城头青草暮。巴山重叠相逢处。","张先《渔家傲·和程公辟赠》"] +["彼月而食,则维其常;此日而食,于何不臧。","佚名《十月之交》"] +["魏王卧内藏兵符,子西掩袂真无辜。","柳宗元《古东门行》"] +["晚春盘马踏青苔,曾傍绿阴深驻。","晏几道《御街行·街南绿树春饶絮》"] +["交河城边鸟飞绝,轮台路上马蹄滑。","岑参《天山雪歌送萧治归京》"] +["必有以也!狐裘蒙戎,匪车不东。","佚名《旄丘》"] +["挟弹飞鹰杜陵北,探丸借客渭桥西。","卢照邻《长安古意》"] +["痴儿了却公家事,快阁东西倚晚晴。","黄庭坚《登快阁》"] +["不留不处,三事就绪。","佚名《大雅·常武》"] +["丹禁漏声衢鼓报。","欧阳修《渔家傲·暖日迟迟花袅袅》"] +["二十八宿罗心胸,九精耿耿贯当中。","李贺《高轩过》"] +["北窗高卧,莫教啼鸟惊著。","辛弃疾《念奴娇·赋雨岩》"] +["使君留客醉厌厌。","苏轼《江神子·黄昏犹是雨纤纤》"] +["民之归仁也,犹水之就下、兽之走圹也。","《孟子·离娄章句上·第九节》"] +["石榴花发满溪津,溪女洗花染白云。","李贺《绿章封事》"] +["叹隙中驹,石中火,梦中身。","苏轼《行香子·述怀》"] +["火山今始见,突兀蒲昌东。","岑参《经火山》"] +["翩翩者鵻,载飞载下,集于苞栩。","佚名《四牡》"] +["辇下风光,山中岁月,海上心情。","刘辰翁《柳梢青·春感》"] +["相逢成夜宿,陇月向人圆。","杜甫《宿赞公房》"] +["蛟胎皮老蒺藜刺,鸊鹈淬花白鹇尾。","李贺《春坊正字剑子歌》"] +["愿斩单于首,长驱静铁关。","李白《从军行》"] +["云澹星疏楚山晓。","吴文英《夜游宫·人去西楼雁杳》"] +["记五湖、清夜推篷,临水一痕月。","吴文英《暗香疏影·夹钟宫赋墨梅》"] +["食必常饱,然后求美;衣必常暖,然后求丽","《说苑·反质》"] +["樽前一曲歌,歌里千重意。","杜安世《卜算子·樽前一曲歌》"] +["破强敌,在谢公处画,从容颐指。","李纲《喜迁莺·晋师胜淝上》"] +["泛此忘忧物,远我遗世情。","陶渊明《饮酒·其四》"] +["禹功本豁达,汉迹方因循。","高适《自淇涉黄河途中作十三首》"] +["赵客缦胡缨,吴钩霜雪明。","李白《侠客行》"] +["生而不有,為而不恃,长而不宰。是谓玄德。","《老子·道经·第十章》"] +["头白灯明里,何须花烬繁。","杜甫《日暮》"] +["别酒劝君君一醉,清润潘郎,又是何郎婿。","苏轼《蝶恋花·送潘大临》"] +["天降丧乱,饥馑荐臻。","佚名《云汉》"] +["闻道汉家天子使,九华帐里梦魂惊。","白居易《长恨歌》"] +["我见楼船壮心目,颇似龙骧下三蜀。","李白《司马将军歌》"] +["别样幽芬,更无浓艳催开处。","纳兰性德《点绛唇·咏风兰》"] +["阑干长对西曛倚。","贺铸《惜余春·急雨收春》"] +["怅断魂、烟水遥远。","姜夔《眉妩·戏张仲远》"] +["暂到下邳受兵略,来投漂母作主人。","李白《猛虎行》"] +["望征路愁迷,离绪难整。","陆叡《瑞鹤仙·梅》"] +["羡春来双燕,飞到玉楼,朝暮相见。","欧阳炯《贺明朝·忆昔花间初识面》"] +["乐哉今岁事,天末稻云黄。","范成大《田舍》"] +["仓无备粟,不可以待凶饥。","《墨子·05章 七患》"] +["败不可处,时不可失,忠不可弃,怀不可从","《国语·晋语·齐姜劝重耳勿怀安》"] +["孤城向水闭,独鸟背人飞。","刘长卿《馀干旅舍》"] +["妖童宝马铁连钱,娼妇盘龙金屈膝。","卢照邻《长安古意》"] +["回车驾言迈,悠悠涉长道。","佚名《回车驾言迈》"] +["明月清风此夜,人世几欢哀。","辛弃疾《水调歌头·盟鸥》"] +["邃院重帘何处,惹得多情,愁对风光。","苏轼《雨中花慢·邃院重帘何处》"] +["居人匹马映林嘶,行人去棹依波转。","晏殊《踏莎行·祖席离歌》"] +["割如判清浊,飘若升云间。","柳宗元《构法华寺西亭》"] +["上下弦柱际,文史辄卷襞。","左思《娇女诗》"] +["鸟无声兮山寂寂,夜正长兮风淅淅。","李华《吊古战场文》"] +["但恐此心终未了,不见不闻还是碍。","苏轼《次韵秦太虚见戏耳聋》"] +["峥嵘甚,讶雪山中断,银汉西倾。","吴伟业《沁园春·观潮》"] +["火山六月应更热,赤亭道口行人绝。","岑参《送李副使赴碛西官军》"] +["宁我负人,毋人负我。","《三国志·魏书·武帝纪》"] +["云山已发兴,玉佩仍当歌。","杜甫《陪李北海宴历下亭》"] +["手把花锄出绣帘,忍踏落花来复去?","曹雪芹《葬花吟》"] +["紫黯红愁无绪,日暮春归甚处?","龚自珍《如梦令·紫黯红愁无绪》"] +["感之欲叹息,对酒还自倾。","李白《春日醉起言志》"] +["吾拟乞归,犬子以田产未置止我,赋此骂之。","辛弃疾《最高楼·吾衰矣》"] +["天南地北双飞客,老翅几回寒暑。","元好问《摸鱼儿·雁丘词 / 迈陂塘》"] +["不狩不猎,胡瞻尔庭有县貆兮?","佚名《伐檀》"] +["不充诎于富贵,不遑遑于所欲。","《梁书·列传·卷五十》"] +["星旗映疏勒,云阵上祁连。","徐陵《关山月》"] +["公尸燕饮,无有后艰。","佚名《凫鹥》"] +["愁闻出塞曲,泪满逐臣缨。","李白《观胡人吹笛》"] +["我心匪席,不可卷也。","佚名《柏舟》"] +["秋浦锦驼鸟,人间天上稀。","李白《秋浦歌十七首》"] +["时事难从无过立,达官非自有生来。","林则徐《赴戍登程口占示家人二首》"] +["随山将万转,趣途无百里。","王维《青溪 / 过青溪水作》"] +["乳燕引雏飞力弱,流莺唤友娇声怯。","辛弃疾《满江红·点火樱桃》"] +["欲向阴关度,阴关晓不开。","张蠙《登单于台》"] +["朔吹飘夜香,繁霜滋晓白。","柳宗元《早梅》"] +["言甘中必苦,何用知其真?","柳宗元《种白蘘荷》"] +["翠玉楼前,惟是有、一波湘水,摇荡湘云。","黄孝迈《湘春夜月·近清明》"] +["君子而诈善,无异小人之肆恶;君子而改节,不若小人之自新。","《菜根谭·概论》"] +["其旂茷茷,鸾声哕哕。","佚名《鲁颂·泮水》"] +["长鲸喷涌不可涉,抚心茫茫泪如珠。","李白《有所思》"] +["今日方教花并蒂,迟迟,终是莲台大士慈。","高鹗《南乡子·戊申秋隽喜晤故人》"] +["支离无趾犹自免,努力低飞逃后患。","柳宗元《跂乌词》"] +["杜陵评书贵瘦硬,此论未公吾不凭。","苏轼《孙莘老求墨妙亭诗》"] +["终朝采蓝,不盈一襜。","佚名《采绿》"] +["南望亭丘,孤秀耸曾城。","苏轼《江城子·梦中了了醉中醒》"] +["代北燕南,应不隔、月明千里。","纳兰性德《满江红·代北燕南》"] +["昼夜勤作息,伶俜萦苦辛。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["精诚由中,故其文语感动人深。","《论衡·卷十三·超奇篇》"] +["半壶秋水荐黄花,香噀西风雨。","吴文英《霜叶飞·重九》"] +["上天不雨粟,何由活烝黎。","姚合《庄居野行》"] +["问君今何行?非商复非戎。","陶渊明《拟古九首》"] +["晓迎秋露一枝新,不占园中最上春。","杜牧《紫薇花》"] +["但欲掩昏旦,遂复经圆缺。","谢灵运《登庐山绝顶望诸峤》"] +["青春花姊不同时。凄凉生较迟。","吴文英《醉桃源·芙蓉》"] +["秋已暮、红稀香少。","李清照《怨王孙·湖上风来波浩渺》"] +["悄无人、舟横野渡。","廖世美《烛影摇红·题安陆浮云楼》"] +["茹素虽佛氏教也,好生非上天意乎。","《格言联璧·惠吉类》"] +["玉楼深锁薄情种。清夜悠悠谁共。","秦观《桃源忆故人·玉楼深锁薄情种》"] +["长剑既照曜,高冠何赩赫。","李白《君马黄》"] +["道由白云尽,春与青溪长。","刘昚虚《阙题》"] +["思无斁思,马斯作。","佚名《鲁颂·駉》"] +["征车自念尘土计,惆怅溪边书细沙。","杜牧《商山麻涧》"] +["鹅鸭不知春去尽,争随流水趁桃花。","晁冲之《春日·阴阴溪曲绿交加》"] +["海国仙人偏耐热。","尹焕《霓裳中序第一·茉莉咏》"] +["夏桀之常违兮,乃遂焉而逢殃。","屈原《离骚》"] +["却回山寺路,闻打暮天钟。","贾岛《雪晴晚望》"] +["狗马饰雕文,土木被缇绣。","《后汉书·列传·宦者列传》"] +["清香晨风远,溽彩寒露浓。","柳宗元《巽公院五咏》"] +["何时到栗里,一见平生亲。","李白《戏赠郑溧阳》"] +["塞上似腾奇女气,江东久殒少微星。","龚自珍《夜坐二首》"] +["碧梧桐锁深深院,谁料得两情,何日教缱绻?","欧阳炯《贺明朝·忆昔花间初识面》"] +["花不尽,柳无穷。","晏殊《喜迁莺·花不尽》"] +["主人东皋上,时稼绕茅屋。","王维《宿郑州》"] +["模糊半已隐瘢胝,诘曲犹能辨跟肘。","苏轼《石鼓歌》"] +["还凭流水送人归。","苏轼《临江仙·风水洞作》"] +["夜帆风驶,满湖烟水苍茫,菰蒲零乱秋声咽。","张元干《石州慢·己酉秋吴兴舟中作》"] +["瞻望弗及,泣涕如雨。","佚名《燕燕》"] +["过沙溪急,霜溪冷,月溪明。","苏轼《行香子·过七里濑》"] +["见世人可取者多,则德日进矣。","《围炉夜话·第五六则》"] +["道人庭宇静,苔色连深竹。","柳宗元《晨诣超师院读禅经》"] +["绕湖烟冷罩波明。","翁元龙《醉桃源·柳》"] +["宁恋本乡一捻土,莫爱他乡万两金。","《西游记·第十二回》"] +["映竹无人见,时闻下子声。","白居易《池上二绝》"] +["遮莫圆明似前度,不知谁续广寒游。","慧霖《闰中秋玩月》"] +["靖安宅里当窗柳,望驿台前扑地花。","白居易《望驿台》"] +["为春憔悴留春住,那禁半霎催归雨。","纳兰性德《菩萨蛮·为春憔悴留春住》"] +["把酒祝东风,且共从容。","欧阳修《浪淘沙·把酒祝东风》"] +["都人齐和《大风歌》。","辛弃疾《西江月·堂上谋臣尊俎》"] +["钓周猎秦安黎元,小鱼鵕兔何足言?","李白《留别于十一兄逖裴十三游塞垣》"] +["二月东湖湖上路,官柳嫩,野梅残。","辛弃疾《江神子·送元济之归豫章》"] +["岂无饮酒?不如叔也。","佚名《叔于田》"] +["或燔或炙,君妇莫莫。","佚名《楚茨》"] +["虫思机杼悲,雀喧禾黍熟。","王维《宿郑州》"] +["霭芳阴未解,乍天气、过元宵。","周端臣《木兰花慢·送人之官九华》"] +["飘飘周八泽,连翩历五山。","曹植《吁嗟篇》"] +["去年为君来,明廷峙鸾鹄。","洪咨夔《送监丞家同年守简池三十韵》"] +["我龙受之,蹻蹻王之造。","佚名《周颂·酌》"] +["楼上谁家红袖,靠阑干无力。","廖世美《好事近·夕景》"] +["沉吟久,怕落红如海,流入春江。","陈维崧《沁园春·咏菜花》"] +["悠悠苍天,曷其有极?","佚名《鸨羽》"] +["厌攀杨柳临清阁,闲采芙蕖傍碧潭。","王涯《秋思赠远二首》"] +["东壁图书府,西园翰墨林。","张说《恩制赐食于丽正殿书院宴赋得林字》"] +["被冷香消新梦觉,不许愁人不起。","李清照《念奴娇·春情》"] +["志欲图篡弑,先害诸贤良。","蔡琰《悲愤诗》"] +["岁暮锄犁傍空室,呼儿登山收橡实。","张籍《野老歌 / 山农词》"] +["鬓丝添得老生涯。","吴文英《思佳客·癸卯除夜》"] +["为谁忙,莫非命。","张可久《普天乐·秋怀》"] +["素女鸣珠佩,天人弄彩球。","李白《宫中行乐词八首》"] +["左军羊长史,衔使秦川,作此与之。","陶渊明《赠羊长史·并序》"] +["风雨满城,何幸两重阳之近;","文天祥《回董提举中秋请宴启》"] +["边让今朝忆蔡邕,无心裁曲卧春风。","李贺《南园十三首》"] +["此生归路愈茫然,无数青山水拍天。","苏轼《慈湖夹阻风》"] +["此地从来可乘兴,留君不住益凄其。","高适《东平别前卫县李寀少府 / 送前卫县李宷少府》"] +["冥冥愁不见,耿耿徒缄忆。","张九龄《感遇十二首》"] +["旧游无处不堪寻。无寻处,惟有少年心。","章良能《小重山·柳暗花明春事深》"] +["听风听雨,春残花落门掩。","吴文英《垂丝钓近·云麓先生以画舫载洛花宴客》"] +["吴地桑叶绿,吴蚕已三眠。","李白《寄东鲁二稚子》"] +["咫尺不可亲,弃我如遗舄。","李白《宣州九日闻崔四侍御与宇文太守游敬亭余时登响山不同此赏醉后寄崔侍御》"] +["上有好者,下必有甚焉者矣。","《孟子·滕文公章句上·第二节》"] +["举眼风光长寂寞,满朝官职独蹉跎。","白居易《醉赠刘二十八使君》"] +["晓峰如画参差碧,藤影风摇拂槛垂。","李白《别匡山》"] +["去年相送,余杭门外,飞雪似杨花。","苏轼《少年游·润州作》"] +["不得乎亲,不可以为人;不顺乎亲,不可以为子。","《孟子·离娄章句上·第二十八节》"] +["归路方浩浩,徂川去悠悠。","李白《月夜江行寄崔员外宗之》"] +["客愁重、时听蕉寒雨碎,泪湿琼钟。","吴文英《高山流水·丁基仲侧室善丝桐赋咏晓达音吕备歌舞之妙》"] +["沙上蘼芜花,秋风已先发。","李贺《黄头郎》"] +["到家应是,童稚牵衣,笑我华颠。","吴激《诉衷情·夜寒茅店不成眠》"] +["我已乞归休,报沙鸥。","杨万里《昭君怨·赋松上鸥》"] +["道书生簿命宜将息,再休耽、怨粉愁香。","纳兰性德《青衫湿遍·悼亡》"] +["约何时再,正春浓酒困,人闲昼永无聊赖。","贺铸《薄幸·淡妆多态》"] +["天不老,情难绝。","张先《千秋岁·数声鶗鴂》"] +["乱云低薄暮,急雪舞回风。","杜甫《对雪》"] +["自从迁播落黔巴,三见江上开新花。","卢僎《十月梅花书赠》"] +["冷雨青灯读书舍,怕离别又早离别。","姚燧《普天乐·浙江秋》"] +["泛舟大河里,积水穷天涯。","王维《渡河到清河作》"] +["那知坚都相草草,客枕幽单看春老。","李贺《仁和里杂叙皇甫湜》"] +["为高必因丘陵,为下必因川泽。","《孟子·离娄章句上·第一节》"] +["一壶浊酒喜相逢,古今多少事,都付笑谈中。","《三国演义·第一回》"] +["驰单车,致缄书,裂荷焚芰接武曳长裾。","贺铸《将进酒·城下路》"] +["花柔玉净,捧觞别有娉婷。","李清照《新荷叶·薄露初零》"] +["飘零疏酒盏,离别宽衣带。","秦观《千秋岁·水边沙外》"] +["策功行赏不淹留,全军藉智谋。","杨广《纪辽东二首》"] +["孔雀自怜金翠尾,临水,认得行人惊不起。","欧阳炯《南乡子·岸远沙平》"] +["归鸿声断残云碧。背窗雪落炉烟直。","李清照《菩萨蛮·归鸿声断残云碧》"] +["我来一长叹,知是东溪莲。","白居易《京兆府栽莲》"] +["利天下者,天下启之;害天下者,天下闭之。","《六韬·武韬·发启》"] +["佳辰强饮食犹寒,隐几萧条戴鹖冠。","杜甫《小寒食舟中作》"] +["岱宗已在眼,一往继前躅。","苏轼《送顿起》"] +["王配于京,世德作求。","佚名《下武》"] +["永丰坊那畔,尽日无人,谁见金丝弄晴昼?","苏轼《洞仙歌·咏柳》"] +["朱弦凄切,云散碧天长。","毛文锡《临江仙·暮蝉声尽落斜阳》"] +["一道鹊桥横渺渺,千声玉佩过玲玲。","徐凝《七夕》"] +["扶桑已在渺茫中,家在扶桑东更东。","韦庄《送日本国僧敬龙归》"] +["荷叶生时春恨生,荷叶枯时秋恨成。","李商隐《暮秋独游曲江》"] +["安土重迁,黎民之性;骨肉相附,人情所愿也。","《汉书·纪·元帝纪》"] +["斜插一枝萱草,凤钗头。","元好问《虞美人·槐阴别院宜清昼》"] +["玉颜憔悴三年,谁复商量管弦。","王建《宫中调笑·团扇》"] +["偷弹清泪寄烟波,见江头故人,为言憔悴如许。","袁去华《剑器近·夜来雨》"] +["结发行事君,慊慊心意关。","陈琳《饮马长城窟行》"] +["率时农夫,播厥百谷。","佚名《周颂·噫嘻》"] +["风枝惊暗鹊,露草覆寒蛩。","戴叔伦《客夜与故人偶集 / 江乡故人偶集客舍》"] +["休拊髀,都生肉。","范成大《满江红·清江风帆甚快作此与客剧饮歌之》"] +["兴罢各分袂,何须醉别颜。","李白《广陵赠别》"] +["遍身罗绮者,不是养蚕人。","张俞《蚕妇》"] +["有画难描雅态,无花可比芳容。","柳永《集贤宾·小楼深巷狂游遍》"] +["花近高楼伤客心,万方多难此登临。","杜甫《登楼》"] +["未随流落水边花,且作飘零泥上絮。","辛弃疾《玉楼春·风前欲劝春光住》"] +["幽兰旋老,杜若还生,水乡尚寄旅。","吴文英《莺啼序·春晚感怀》"] +["兴来携妓恣经过,其若杨花似雪何!","李白《忆旧游寄谯郡元参军》"] +["壮士愤,雄风生。","李白《临江王节士歌》"] +["直如朱丝绳,清如玉壶冰。","鲍照《代白头吟》"] +["当路谁相假,知音世所稀。","孟浩然《留别王侍御维 / 留别王维》"] +["物其旨矣,维其偕矣!","佚名《鱼丽》"] +["造端何其锐,临事竟趑趄。","柳宗元《咏荆轲》"] +["之子在万里,江湖逈且深。","曹植《杂诗七首》"] +["四牡騑騑,六辔如琴。","佚名《车舝》"] +["人珍我弃、人弃我取。","《浮生六记·浪游记快》"] +["旧赏轻抛,到此成游宦。","柳永《迷神引·一叶扁舟轻帆卷》"] +["民之多辟,无自立辟。","佚名《板》"] +["归路畏逢虎,况闻岩下风。","厉鹗《灵隐寺月夜》"] +["四月十七,正是去年今日,别君时。","韦庄《女冠子·四月十七》"] +["中原父老莫空谈,逢着王人诉不堪。","杨万里《初入淮河四绝句》"] +["中军置酒饮归客,胡琴琵琶与羌笛。","岑参《白雪歌送武判官归京》"] +["凤楼琪树,惆怅刘郎一去,正春深。","鹿虔扆《女冠子·凤楼琪树》"] +["轻衫未揽,犹将泪点偷藏。","史达祖《夜合花·柳锁莺魂》"] +["思无邪思,马斯徂。","佚名《鲁颂·駉》"] +["东厢月,一天风露,杏花如雪。","范成大《秦楼月·楼阴缺》"] +["一春憔悴有谁怜。","赵长卿《临江仙·暮春》"] +["流红千浪,缺月孤楼,总难留燕。","吴文英《瑞鹤仙·晴丝牵绪乱》"] +["啼到春归无寻处,苦恨芳菲都歇。","辛弃疾《贺新郎·别茂嘉十二弟》"] +["叶里黄鹂时一弄。","欧阳修《渔家傲·五月榴花妖艳烘》"] +["红深绿暗径相交,抱暖含芳披紫袍。","温庭筠《寒食日作》"] +["锡尔纯嘏,子孙其湛。","佚名《宾之初筵》"] +["夜夜除非,好梦留人睡。","范仲淹《苏幕遮·怀旧》"] +["运往无淹物,年逝觉已催。","谢灵运《岁暮》"] +["渐觉云海沈沈,洞天日晚。","柳永《破阵乐·露花倒影》"] +["夜深知雪重,时闻折竹声。","白居易《夜雪》"] +["高山有崖,林木有枝。","曹丕《善哉行·其一》"] +["经行几处江山改,多少亲朋尽白头。","辛弃疾《鹧鸪天·欲上高楼去避愁》"] +["门前记取垂杨树,只藏他、三两秋莺。","郭麐《高阳台·将反魏塘疏香女子亦以次日归吴下置酒话别离怀惘惘》"] +["王公何慷慨,千载仰雄名。","李白《金陵新亭》"] +["近作十日欢,远为千载期。","李白《寻鲁城北范居士失道落苍耳中见范置酒摘苍耳作》"] +["又莫是东风逐君来,便吹散眉间一点春皱。","苏轼《洞仙歌·咏柳》"] +["大丈夫处世,当埽除天下","《后汉书·列传·陈王列传》"] +["素富贵,行乎富贵;素贫贱,行乎贫贱","《中庸·第十四章》"] +["剑南春色还无赖,触忤愁人到酒边。","杜甫《送路六侍御入朝》"] +["相看似相识,脉脉不得语。","孟浩然《耶溪泛舟》"] +["汗手遗香渍,痕眉染黛烟。","李煜《书灵筵手巾》"] +["琴瑟在御,莫不静好。","佚名《女曰鸡鸣》"] +["惭君湍波苦,千里远从之。","李白《窜夜郎于乌江留别宗十六璟》"] +["一叶落,搴珠箔。","李存勖《一叶落·一叶落》"] +["时人伤之,为诗云尔。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["蔼蔼王多吉人,维君子命,媚于庶人。","佚名《卷阿》"] +["敦弓既坚,四鍭既钧,舍矢既均,序宾以贤。","佚名《行苇》"] +["元和十年,予左迁九江郡司马。","白居易《琵琶行 / 琵琶引》"] +["乃幽武置大窖中,绝不饮食。天雨雪。","班固《苏武传(节选)》"] +["求我庶士,迨其今兮。","佚名《摽有梅》"] +["后来视今犹视昔,过眼百年如风灯。","苏轼《孙莘老求墨妙亭诗》"] +["劳劳燕子人千里,落落梨花雨一枝。","张炎《鹧鸪天·楼上谁将玉笛吹》"] +["水石潺湲万壑分,烟光草色俱氛氲。","李白《观元丹丘坐巫山屏风》"] +["村北村南,谷雨才耕遍。秀麦连冈桑叶贱。","范成大《蝶恋花·春涨一篙添水面》"] +["为政以德,譬如北辰,居其所,而众星共之。","《论语·为政篇》"] +["闭宝匣,掩金铺, 倚屏拖袖愁如醉。","魏承班《木兰花·小芙蓉》"] +["今彼征敛者,迫之如火煎。","元结《贼退示官吏》"] +["君者,源也;水者,流也。源清则流清,源浊则流浊。","《太平御览·治道部·卷一》"] +["采桑秦氏女,织锦窦家妻。","薛道衡《昔昔盐》"] +["春风无复情,吹我梦魂散。","李白《大堤曲》"] +["一节见则百节知矣。","《说苑·尊贤》"] +["重云暗阁,春霖一片。","吴文英《瑞龙吟·德清清明竞渡》"] +["都人齐和《大风歌》。","辛弃疾《西江月·堂上谋臣尊俎》"] +["恬澹无人见,年年长自清。","储光羲《咏山泉 / 山中流泉》"] +["苍穹浩茫茫,万劫太极长。","李白《短歌行》"] +["罔流涕以聊虑兮,惟著意而得之。","宋玉《九辩》"] +["红蓼渡头秋正雨,印沙鸥迹自成行,整鬟飘袖野风香。","薛昭蕴《浣溪沙·红蓼渡头秋正雨》"] +["尔去安可迟?瑶草恐衰歇。","李白《赠别王山人归布山》"] +["人心似铁,官法如炉。","《增广贤文·上集》"] +["低娇笑容疑掩口,凝思怨人如断肠。","白居易《牡丹芳》"] +["居逆境中,周身皆针砭药石,砥节砺行而不觉;处顺境内,满前尽兵刃戈矛,销膏靡骨而不知。","《菜根谭·概论》"] +["守节自誓,亲诲之学。","欧阳修《画地学书》"] +["我今出城勤送子,沽酒不惜典弊袍。","梅尧臣《东城送运判马察院》"] +["一生大笑能几回,斗酒相逢须醉倒。","岑参《凉州馆中与诸判官夜集》"] +["驾彼四骆,载骤骎骎。","佚名《四牡》"] +["弃绝父母恩,吞声行负戈。","杜甫《前出塞九首》"] +["何彼襛矣,唐棣之华?","佚名《何彼襛矣》"] +["十月九日,孟亨之置酒秋香亭。","苏轼《定风波·两两轻红半晕腮》"] +["嗟万事难忘,惟是轻别。","周邦彦《浪淘沙慢·晓阴重》"] +["风入罗帏,爽入疏棂,月照纱窗。","郑光祖《蟾宫曲·梦中作》"] +["弄喜音、鹊绕庭花,红帘影动。","吴文英《宴清都·送马林屋赴南宫分韵得动字》"] +["红颜零落岁将暮,寒光宛转时欲沉。","鲍照《拟行路难十八首》"] +["上林苑里花徒发,细柳营前叶漫新。","杜审言《春日京中有怀》"] +["笙歌散尽游人去,始觉春空。","欧阳修《采桑子·群芳过后西湖好》"] +["嗟叹之不足,乃次其韵。","苏轼《满江红·忧喜相寻》"] +["别裁伪体亲风雅,转益多师是汝师。","杜甫《戏为六绝句》"] +["清香更何用,犹发去年枝。","李煜《梅花》"] +["风拍波惊,露零秋觉,断绿衰红江上。","吴文英《法曲献仙音·秋晚红白莲》"] +["从臣皆半醉,天子正无愁。","李商隐《陈后宫》"] +["岂无他人?不如我同父。","佚名《杕杜》"] +["当杯已入手,歌妓莫停声。","孟浩然《春中喜王九相寻 / 晚春》"] +["况欲系其尾,虽勤知奈何。","苏轼《守岁》"] +["是节东篱菊,纷披为谁秀。","杜甫《九日寄岑参》"] +["昨夜江边春水生,艨艟巨舰一毛轻。","朱熹《活水亭观书有感二首·其二》"] +["朝屯雪山下,暮宿青海旁。","韦元甫《木兰歌》"] +["午睡醒来慵一饷。","晏几道《蝶恋花·初捻霜纨生怅望》"] +["老夫不知其所往,足茧荒山转愁疾。","杜甫《观公孙大娘弟子舞剑器行》"] +["禁门宫树月痕过,媚眼惟看宿鹭窠。","张祜《赠内人》"] +["淮阴市井笑韩信,汉朝公卿忌贾生。","李白《行路难三首》"] +["扈从良可赋,终乏掞天才。","宋之问《扈从登封途中作》"] +["缉缉翩翩,谋欲谮人。","佚名《巷伯》"] +["沅湘流不尽,屈子怨何深。","戴叔伦《过三闾庙》"] +["倚阑凝望,独立渔翁满江雪。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["西人之子,粲粲衣服。","佚名《大东》"] +["村北村南,谷雨才耕遍。秀麦连冈桑叶贱。","范成大《蝶恋花·春涨一篙添水面》"] +["感君绸缪逐君去,成君家计良辛苦。","王安石《君难托》"] +["不孝有三,无后为大。","《孟子·离娄章句上·第二十六节》"] +["微吟罢,凭征鞍无语,往事千端。","苏轼《沁园春·孤馆灯青》"] +["乐只君子,福履绥之。","佚名《樛木》"] +["儿大诗书女丝麻,公但读书煮春茶。","黄庭坚《送王郎》"] +["红袖织绫夸柿蒂,青旗沽酒趁梨花。","白居易《杭州春望》"] +["一曲菱歌晚,惊飞欲下鸥。","朱熹《奉同张敬夫城南二十咏》"] +["树之榛栗,椅桐梓漆,爰伐琴瑟。","佚名《鄘风·定之方中》"] +["父在,观其志;父没,观其行;三年无改于父之道,可谓孝矣。","《论语·学而篇》"] +["低回又作荆州梦,落日孤云始欲愁。","曾几《寓居吴兴》"] +["对此石上月, 长歌醉芳菲。","李白《春日独酌二首》"] +["夜热依然午热同,开门小立月明中。","杨万里《夏夜追凉》"] +["直待黄昏风卷霁,金滟滟,玉团团。","陈著《江城子·中秋早雨晚晴》"] +["东风动百物,草木尽欲言。","李白《长歌行》"] +["慈父断子首,狂走无容躯。","柳宗元《咏荆轲》"] +["人行明镜中,鸟度屏风里。","李白《清溪行 / 宣州清溪》"] +["算春长不老,人愁春老,愁只是、人间有。","晁补之《水龙吟·次歆林圣予惜春》"] +["故国山川,故园心眼,还似王粲登楼。","周密《一萼红·登蓬莱阁有感》"] +["汝归秦兮弹秦声,秦声悲兮聊送汝。","岑参《秦筝歌送外甥萧正归京》"] +["早知恁地难拚,悔不当时留住。","柳永《昼夜乐·洞房记得初相遇》"] +["舌为利害本,口是祸福门。","《醒世恒言·卷二十九》"] +["惴惴小心,如临于谷。","佚名《小宛》"] +["恼烟撩露,留我须臾住。","朱淑真《清平乐·夏日游湖》"] +["雪照山城玉指寒,一声羌管怨楼间。","刘著《鹧鸪天·雪照山城玉指寒》"] +["雷惊天地龙蛇蛰,雨足郊原草木柔。","黄庭坚《清明》"] +["柳老悲桓,松高对阮,未办为邻地。","姜夔《永遇乐·次韵辛克清先生》"] +["一首情词,一首情诗。","唐寅《一剪梅·雨打梨花深闭门》"] +["天到水中尽,舟随树杪行。","严羽《江行》"] +["命归亭长占王业,人起炎方见霸才。","廖燕《粤王台怀古》"] +["父母呼 应勿缓 父母命 行勿懒","《弟子规·入则孝》"] +["大丈夫处世,当努力建功立业,著鞭在先。今若不取,为他人所取,悔之晚矣。","《三国演义·第六十回》"] +["南风忽不尽,西师日侵蹙。","李百药《郢城怀古》"] +["年来鞍马困尘埃,赖有青山豁我怀。","张耒《初见嵩山》"] +["苍茫远山口,豁达胡天开。","高适《自蓟北归》"] +["千里渥洼种,名动帝王家。","辛弃疾《水调歌头·寿赵漕介庵》"] +["冰玉半湾尘不惹,停凝欲挽东流驾。","王夫之《蝶恋花·湘水经东安县东》"] +["郭门临渡头,村树连谿口。","王维《新晴野望》"] +["微微风簇浪,散作满河星。","查慎行《舟夜书所见》"] +["忽遗苍生望,独与洪崖群。","李白《题元丹丘颍阳山居》"] +["长歌负轻策,平野望烟归。","孟浩然《采樵作》"] +["徒隶肃曹官,征赋参有司。","柳宗元《哭连州凌员外司马》"] +["声一无听,物一无文","《国语·郑语·史伯为桓公论兴衰》"] +["穷寇不可追也,遁辞不可攻也,贫民不可威也。","《格言联璧·接物类》"] +["欲寄边城客,路远谁能持。","李爽《芳树》"] +["忽魂悸以魄动,恍惊起而长嗟。","李白《梦游天姥吟留别 / 梦游天姥山别东鲁诸公》"] +["愁痕满地无人省,露湿琅玕影。","纳兰性德《虞美人·愁痕满地无人省》"] +["夜来霜压栈,骏骨折西风。","李贺《马诗二十三首》"] +["鸟啼花发柳含烟,掷却风光忆少年。","顾况《登楼望水》"] +["夜半月高弦索鸣,贺老琵琶定场屋。","元稹《连昌宫词》"] +["秋风吹木叶,还似洞庭波。","王褒2《渡河北》"] +["人生几何时,怀忧终年岁。","蔡琰《悲愤诗》"] +["土花庭甃,虫网阑干。","史达祖《玉蝴蝶·晚雨未摧宫树》"] +["心和得天真,风俗由太古。","李白《赠清漳明府侄聿》"] +["鱼丽于罶,鰋鲤。","佚名《鱼丽》"] +["于疆于理,至于南海。","佚名《大雅·江汉》"] +["对宿烟收,春禽静,飞雨时鸣高屋。","周邦彦《大酺·越调春雨》"] +["湖上春既早,田家日不闲。","丘为《题农父庐舍》"] +["阵云埋夏首,穷阴惨荒谷。","李百药《郢城怀古》"] +["城头铁鼓声犹振,匣里金刀血未干。","王昌龄《出塞二首》"] +["金灶生烟埃,玉潭秘清谧。","李白《望黄鹤楼》"] +["声无小而不闻,行无隐而不形。","《荀子·劝学》"] +["指也者,天下之所无也;物也者,天下之所有也。","《公孙龙子·指物论》"] +["料君终自致,勋业在临洮。","高适《送蹇秀才赴临洮》"] +["有美一人,婉如清扬。","佚名《野有蔓草》"] +["短长肥瘦各有态,玉环飞燕谁敢憎。","苏轼《孙莘老求墨妙亭诗》"] +["只落得,填膺百感,总茫茫、不关离别。","纳兰性德《琵琶仙·中秋》"] +["事先败而后成,曰失之东隅,收之桑榆;事将成而终止,曰为山九仞,功亏一篑。","《幼学琼林·卷一·地舆》"] +["多情只有春庭月,犹为离人照落花。","张泌《寄人》"] +["但蜂媒蝶使,时叩窗隔。","周邦彦《六丑·落花》"] +["北阙献书寝不报,南山种田时不登。","王维《不遇咏》"] +["归来宴平乐,美酒斗十千。","曹植《名都篇》"] +["既多受祉,黄发儿齿。","佚名《鲁颂·閟宫》"] +["人以愚虞圣,我以不愚虞圣;人以奇期圣,我以不奇期圣。","《阴符经·下篇》"] +["一带江山如画,风物向秋潇洒。","张昪《离亭燕·一带江山如画》"] +["更坐待、千岩月落,城头眇眇啼鸟。","姜夔《汉宫春·次韵稼轩蓬莱阁》"] +["所志在功名,离别何足叹。","陆龟蒙《别离》"] +["毋金玉尔音,而有遐心。","佚名《白驹》"] +["愿书万本诵万遍,口角流沫右手胝。","李商隐《韩碑》"] +["愿因三青鸟,更报长相思。","李白《相逢行二首》"] +["拜华星之坠几,约明月之浮槎。","文天祥《回董提举中秋请宴启》"] +["苗疏税多不得食,输入官仓化为土。","张籍《野老歌 / 山农词》"] +["问欢情几许?早收拾、新愁重织。","张埜《夺锦标·七夕》"] +["秋夜香闺思寂寥,漏迢迢。","顾夐《杨柳枝·秋夜香闺思寂寥》"] +["从古难消豪气,也只书空咄咄。","吴藻《金缕曲·闷欲呼天说》"] +["雨露之所濡,甘苦齐结实。","杜甫《北征》"] +["古人今人若流水,共看明月皆如此。","李白《把酒问月·故人贾淳令予问之》"] +["帝省其山,柞棫斯拔,松柏斯兑。","佚名《皇矣》"] +["白日放歌须纵酒,青春作伴好还乡。","杜甫《闻官军收河南河北》"] +["蔽日旌旗,连云樯橹,白骨纷如雪。","萨都剌《念奴娇·登石头城次东坡韵》"] +["泬漻兮天高而气清,寂寥兮收潦而水清。","宋玉《九辩》"] +["已驾七香车,心心待晓霞。","李商隐《壬申七夕》"] +["识时务者为俊杰,昧先几者非明哲。","《幼学琼林·卷三·人事》"] +["济济多士,秉文之德。","佚名《周颂·清庙》"] +["江上渡,江边路。","戴复古《满江红·赤壁怀古》"] +["玩人丧德,玩物丧志。","《尚书·周书·旅獒》"] +["大力运天地,羲和无停鞭。","李白《长歌行》"] +["每见寒士将达,必有一段谦光可掬。","《了凡四训·谦德之效》"] +["白骨归黄泉,肌体乘尘飞。","孔融《杂诗二首》"] +["巨舰只缘因利往,扁舟亦是为名来。","李清照《钓台 / 夜发严滩》"] +["寒食花枝插满头,蒨裙青袂几扁舟。","范成大《春日田园杂兴》"] +["楚楚者茨,言抽其棘。","佚名《楚茨》"] +["自责之外,无胜人之术。自强之外,无上人之术。","《格言联璧·持躬类》"] +["碧海年年,试问取、冰轮为谁圆缺?","纳兰性德《琵琶仙·中秋》"] +["命也可奈何,长戚自令鄙。","潘安《悼亡诗三首》"] +["柳梢残日弄微晴。","周邦彦《浣沙溪·翠葆参差竹径成》"] +["夜读《李广传》,不能寐。","辛弃疾《八声甘州·故将军饮罢夜归来》"] +["式讹尔心,以畜万邦。","佚名《节南山》"] +["谁解乘舟寻范蠡,五湖烟水独忘机。","温庭筠《利州南渡》"] +["听来咫尺无寻处,寻到旁边却不声。","杨万里《初秋行圃》"] +["玉奴有姊,先占立、墙阴春早。","吴文英《天香·蜡梅》"] +["远客投名贤,真堪写怀抱。","李白《于五松山赠南陵常赞府》"] +["握手经年别,惊心九日霜。","妙信《九日酬诸子》"] +["南昌仙人赵夫子,妙年历落青云士。","李白《当涂赵炎少府粉图山水歌》"] +["我求懿德,肆于时夏,允王保之。","佚名《周颂·时迈》"] +["纵被无情弃,不能羞。","韦庄《思帝乡·春日游》"] +["天涯地角有穷时,只有相思无尽处。","晏殊《玉楼春·春恨》"] +["芳草年年惹恨幽。想前事悠悠。","石延年《燕归梁·春愁》"] +["如是灭度无量无数无边众生。实无众生得灭度者。","《金刚经·大乘正宗》"] +["人异而心异,择其弱者以攻之,其神必溃。","《罗织经·问罪卷第十》"] +["上寿大安宫,复如正观初。","陆游《观大散关图有感》"] +["彼汾一方,言采其桑。","佚名《汾沮洳》"] +["江寒水不流,鱼嚼梅花影。","释敬安《题寒江钓雪图》"] +["援琴鸣弦发清商,短歌微吟不能长。","曹丕《燕歌行二首·其一》"] +["一尺鲈鱼新钓得,儿孙吹火荻花中。","郑谷《淮上渔者》"] +["小园几许,收尽春光。","秦观《行香子·树绕村庄》"] +["作赋吟诗空自好,不直一杯秋露。","黄升《酹江月·夜凉》"] +["又是一窗灯影、两愁人。","周邦彦《虞美人·廉纤小雨池塘遍》"] +["恸哭兮远望,见苍梧之深山。","李白《远别离》"] +["竹叶樽空翻采燕,九枝灯灺颤金虫。","纳兰性德《浣溪沙·庚申除夜》"] +["云物不殊乡国异,教儿且覆掌中杯。","杜甫《小至》"] +["别来闲整钓鱼竿,思入水云寒。","潘阆《酒泉子·长忆西湖》"] +["可惜一溪风月,莫教踏碎琼瑶。","苏轼《西江月·顷在黄州》"] +["鸟兽犹知怀德兮,何云贤士之不处?","宋玉《九辩》"] +["女儿年几十五六,窈窕无双颜如玉。","萧衍《东飞伯劳歌》"] +["民言无嘉,惨莫惩嗟。","佚名《节南山》"] +["一家二贵,事乃无功。","《韩非子·扬权》"] +["惆怅晓莺残月,相别,从此隔音尘。","韦庄《荷叶杯·记得那年花下》"] +["永言孝思,昭哉嗣服。","佚名《下武》"] +["错教双鬓受东风,看吹绿影成丝早。","纳兰性德《踏莎行·倚柳题笺》"] +["我龟既厌,不我告犹。","佚名《小旻》"] +["正是客心孤迥处,谁家红袖凭江楼?","杜牧《南陵道中 / 寄远》"] +["西秦北赵各称高,池上张筵列我曹。","汪遵《渑池》"] +["主人东皋上,时稼绕茅屋。","王维《宿郑州》"] +["天长水阔厌远涉,访古始及平台间。","李白《梁园吟》"] +["曾逐东风拂舞筵,乐游春苑断肠天。","李商隐《柳》"] +["东方岁星大明宫,南山喜气摇晴空。","鲍防《元日早朝行》"] +["花意争春,先出岁寒枝。","辛弃疾《江神子·赋梅寄余叔良》"] +["遥看是君家,松柏冢累累。","佚名《十五从军行 / 十五从军征》"] +["五陵北原上,万古青濛濛。","岑参《与高适薛据同登慈恩寺浮图》"] +["沉吟日落寒鸦起,却望柴荆独自回。","朱熹《次韵雪后书事二首》"] +["皓齿信难开,沉吟碧云间。","李白《西施 / 咏苎萝山》"] +["雅称个、花仙为首。","顾太清《玉烛新·白海棠》"] +["千岩烽火连沧海,两岸旌旗绕碧山。","李白《永王东巡歌十一首》"] +["群公先正,则不我闻。","佚名《云汉》"] +["愿为忠臣安可得?","佚名《战城南》"] +["故乡门巷荆棘底,中原君臣豺虎边。","杜甫《昼梦》"] +["本为休明人,斩虏素不闲。","李白《豫章行》"] +["为谁频断续,相和砧杵?","姜夔《齐天乐·蟋蟀》"] +["天涯一去无穷已,蓟门迢递三千里。","卢思道《从军行》"] +["或肆之筵,或授之几。","佚名《行苇》"] +["一轮秋影转金波,飞镜又重磨。","辛弃疾《太常引·建康中秋夜为吕叔潜赋》"] +["六月栖栖,戎车既饬。","佚名《六月》"] +["笑怕蔷薇罥,行忧宝瑟僵。","苏轼《南歌子·有感》"] +["尔公尔侯,逸豫无期?","佚名《白驹》"] +["持操岂独古,无闷征在今。","谢灵运《登池上楼》"] +["不意伶伦子,吹之学凤鸣。","陈子昂《与东方左史虬修竹篇》"] +["南风为解佳人愠。","苏轼《渔家傲·送台守江郎中》"] +["枝枝相纠结,叶叶竞飘扬。","李白《古意》"] +["安得夫差水犀手,三千强弩射潮低。","苏轼《八月十五日看潮五绝》"] +["夫尚贤者,政之本也。","《墨子·08章 尚贤(上)》"] +["南风不用蒲葵扇,纱帽闲眠对水鸥。","李嘉祐《寄王舍人竹楼》"] +["君莫上,古原头。","康与之《诉衷情令·长安怀古》"] +["一朝和殷羹,光气为列星。","李白《纪南陵题五松山》"] +["无非无仪,唯酒食是议,无父母诒罹。","佚名《斯干》"] +["良朋悠邈,搔首延伫。","陶渊明《停云》"] +["今孝子之为亲度也,将奈何哉?","《墨子·22章 节用(下)》"] +["故人溪上,挂愁无奈,烟梢月树。","史达祖《留春令·咏梅花》"] +["胜概日相与,思君心郁陶。","岑参《巩北秋兴寄崔明允》"] +["离愁秋水远,醉眼晓帆开。","张元干《临江仙·送宇文德和被召赴行在所》"] +["风雨梨花寒食过,几家坟上子孙来?","高启《送陈秀才还沙上省墓》"] +["黄金陌,茫茫十里春云白。","宋徵舆《忆秦娥·杨花》"] +["世事相违每如此,好怀百岁几回开?","陈师道《绝句·书当快意读易尽》"] +["参横月落,客绪从谁托。","纳兰性德《清平乐·发汉儿村题壁》"] +["乱石穿空,惊涛拍岸,卷起千堆雪。","苏轼《念奴娇·赤壁怀古》"] +["舒夭绍兮,劳心惨兮。","佚名《月出》"] +["螽斯羽,薨薨兮。","诗经《螽斯》"] +["津亭秋月夜,谁见泣离群?","王勃《江亭夜月送别二首》"] +["若将花酒比车马,他得驱驰我得闲。","唐寅《桃花庵歌》"] +["千里一回首,万里一长歌。","李白《书情题蔡舍人雄》"] +["心地无非自性戒,心地无痴自性慧,心地无乱自性定","《六祖坛经·顿渐·第一节》"] +["殷其雷,在南山之下。","佚名《殷其雷》"] +["清颍东流,愁目断,孤帆明灭。","苏轼《满江红·怀子由作》"] +["冉冉年时暮,迢迢天路征。","陆机《梁甫吟》"] +["秋深橡子熟,散落榛芜冈。","皮日休《橡媪叹》"] +["静绕珍丛底,成叹息。","周邦彦《六丑·落花》"] +["”既乡校不毁,而郑国以理。","韩愈《子产不毁乡校颂》"] +["童稚情亲四十年,中间消息两茫然。","杜甫《送路六侍御入朝》"] +["宫衣亦有名,端午被恩荣。","杜甫《端午日赐衣》"] +["玉梅消瘦,恨东皇命薄。","汪元量《传言玉女·钱塘元夕》"] +["转思量、镇长堕睫。","周邦彦《三部乐·商调梅雪》"] +["秉忠贞之诚,守退让之实。","《三国志·魏书·荀彧攸贾诩传》"] +["展如之人兮,邦之媛也!","佚名《君子偕老》"] +["贷一每输百,朘削痛入髓。","钱嶫《悯黎咏》"] +["芳草芊绵,尚忆江南岸。","欧阳修《蝶恋花·画阁归来春又晚》"] +["但去莫复问,白云无尽时。","王维《送别》"] +["采药归来,独寻茅店沽新酿。","陆游《点绛唇·采药归来》"] +["总不解、将春系住。","吴文英《西子妆慢·湖上清明薄游》"] +["悠悠苍天,曷其有极?","佚名《鸨羽》"] +["少陵无人谪仙死,才薄将奈石鼓何。","韩愈《石鼓歌》"] +["曲终人醉。多似浔阳江上泪。","朱敦儒《减字木兰花·刘郎已老》"] +["天边看渌水,海上见青山。","李白《广陵赠别》"] +["咸阳之南,直望五千里,见云峰之崔嵬。","李白《剑阁赋》"] +["秋而载尝,夏而楅衡,白牡骍刚。","佚名《鲁颂·閟宫》"] +["及至移根上林苑,王孙方恨买无因。","鱼玄机《卖残牡丹》"] +["伤心事,是年年冰合,在在风寒。","陈人杰《沁园春·丁酉岁感事》"] +["狂风吹古月,窃弄章华台。","李白《司马将军歌》"] +["新白发,旧黄金。","苏轼《更漏子·送孙巨源》"] +["门有车马客,驾言发故乡。","陆机《门有车马客行》"] +["南园春半踏青时,风和闻马嘶。","欧阳修《阮郎归·南园春半踏青时》"] +["”执手分道去,各各还家门。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["萧萧细雨作寒色,厌厌尽醉安可辞。","梅尧臣《醉中留别永叔子履》"] +["三饮便得道,何须苦心破烦恼。","皎然《饮茶歌诮崔石使君》"] +["何以致区区?耳中双明珠。","繁钦《定情诗》"] +["双鸳池沼水溶溶,南北小桡通。","张先《一丛花令·伤高怀远几时穷》"] +["酹酒苍茫,倚歌平远,亭上玉虹腰冷。","吴文英《十二郎·垂虹桥》"] +["人不可以无耻,无耻之耻,无耻矣。","《孟子·尽心章句上·第六节》"] +["羿昔落九乌,天人清且安。","李白《古朗月行》"] +["荒城虚照碧山月,古木尽入苍梧云。","李白《梁园吟》"] +["纵明月相思千里隔。梦咫尺。勤书尺。","贺铸《琴调相思引·送范殿监赴黄岗》"] +["俾尔昌而炽,俾尔寿而富。","佚名《鲁颂·閟宫》"] +["孤臣霜发三千丈,每岁烟花一万重。","陈与义《伤春》"] +["神机日夜运,甘泽高下普。","张孝祥《湖湘以竹车激水粳稻如云书此能仁院壁》"] +["父母所欲为者,我继述之;父母所重念者,我亲厚之。","《格言联璧·齐家类》"] +["缥缈危亭,笑谈独在千峰上。","叶梦得《点绛唇·绍兴乙卯登绝顶小亭》"] +["霜殒芦花泪湿衣,白头无复倚柴扉。","与恭《思母》"] +["莫遣旁人惊去,老夫静处闲看。","辛弃疾《清平乐·检校山园书所见》"] +["至今风雨阴寒夜,直到天明痛不眠。","白居易《新丰折臂翁》"] +["安石须起,要苏天下苍生。","李清照《新荷叶·薄露初零》"] +["虽是一般,惟高一着。","王旭《踏莎行·雪中看梅花》"] +["细雨湿流光,芳草年年与恨长。","冯延巳《南乡子·细雨湿流光》"] +["黄童白叟聚睢盱。","苏轼《浣溪沙·照日深红暖见鱼》"] +["湿云不动溪桥冷。","苏轼《菩萨蛮·湿云不动溪桥冷》"] +["蹶父孔武,靡国不到。","佚名《韩奕》"] +["余亦爱此人,丹霄冀飞翻。","李白《书情题蔡舍人雄》"] +["一条藤径绿,万点雪峰晴。","李白《冬日归旧山》"] +["相送泪沾衣,天涯独未归。","岑参《送四镇薛侍御东归》"] +["学校,所以养士也。","《明夷待访录·学校》"] +["以此难挂席,佳期益相思。","李白《新林浦阻风寄友人》"] +["祸转亡胡岁,势成擒胡月。","杜甫《北征》"] +["俯仰成陈迹,叹百年谁在,阑槛孤凭。","张炎《忆旧游·登蓬莱阁》"] +["老人行即到,到即行人老。","苏轼《菩萨蛮·回文》"] +["玉樽盈桂酒,河伯献神鱼。","曹植《仙人篇》"] +["将无七擒略,鲁女惜园葵。","李白《书怀赠南陵常赞府》"] +["翠微高处,故人帽底,一年最好,偏是重阳。","吴文英《惜黄花慢·菊》"] +["田家望望惜雨干,布谷处处催春种。","杜甫《洗兵马》"] +["一霎荷塘过雨,明朝便是秋声。","项鸿祚《清平乐·池上纳凉》"] +["门前行乐客,白马嘶春色。","牛峤《菩萨蛮·风帘燕舞莺啼柳》"] +["振蛰春潜至,湘南人未归。","刘禹锡《元日感怀》"] +["与肩挑贸易,毋占便宜;见贫苦亲邻,须加温恤。","《朱子家训·全文》"] +["总是向人深处,当时枉道无情。","陆游《朝中措·代谭德称作》"] +["几日添憔悴,虚飘飘柳絮飞。","关汉卿《大德歌·春》"] +["颙颙卬卬,如圭如璋,令闻令望。","佚名《卷阿》"] +["羽扇纶巾,谈笑间,樯橹灰飞烟灭。","苏轼《念奴娇·赤壁怀古》"] +["终朝采绿,不盈一匊。","佚名《采绿》"] +["到而今,独伴梨花影,冷冥冥、尽意凄凉。","纳兰性德《青衫湿遍·悼亡》"] +["散入珠帘湿罗幕,狐裘不暖锦衾薄。","岑参《白雪歌送武判官归京》"] +["历览千载书,时时见遗烈。","陶渊明《癸卯岁十二月中作与从弟敬远》"] +["罗衣花下倚娇憨。","谭献《临江仙·和子珍》"] +["节制非桓文,军师拥熊虎。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["尔去安可迟?瑶草恐衰歇。","李白《赠别王山人归布山》"] +["玉肌铅粉傲秋霜。","苏轼《少年游·玉肌铅粉傲秋霜》"] +["卷舒固在我,何事空摧残。","李白《秋日炼药院镊白发赠元六兄林宗》"] +["与君剩采江山景,裁取新诗入帝乡。","杜荀鹤《冬末同友人泛潇湘》"] +["馨香岁欲晚,感叹情何极。","张九龄《感遇十二首》"] +["徐方不回,王曰还归。","佚名《大雅·常武》"] +["命由我作,福自己求。","《了凡四训·立命之学》"] +["卖眼掷春心,折花调行客。","李白《越女词五首》"] +["落絮飞花日又西,踏青无侣草萋萋。","李中《钟陵禁烟寄从弟》"] +["莫怨孤舟无定处,此身自是一孤舟。","杨万里《泊平江百花洲》"] +["人之患在好为人师。","《孟子·离娄章句上·第二十三节》"] +["雨馀风软碎鸣禽。","章良能《小重山·柳暗花明春事深》"] +["寸珠片玉,争似此、浓欢无价。","柳永《抛球乐·晓来天气浓淡》"] +["此举开青徐,旋瞻略恒碣。","杜甫《北征》"] +["张弓倚残魄,不独汉家营。","杜甫《八月十五夜月二首》"] +["识时务者为俊杰,昧先几者非明哲。","《幼学琼林·卷三·人事》"] +["时三子已归自外家去远即相忘,归近不可忍。","陈师道《示三子》"] +["巧言如簧,颜之厚矣。","佚名《巧言》"] +["算没个、人传与。","黄庭坚《望江东·江水西头隔烟树》"] +["拔剑击大荒,日收胡马群;","杜甫《后出塞五首》"] +["中夜天中望,忆君思见君。","李白《赠王判官时余归隐居庐山屏风叠》"] +["匪伊卷之,发则有旟。","佚名《都人士》"] +["今年不得地,憔悴府门前。","白居易《京兆府栽莲》"] +["弃逐久枯槁,迨今始开颜。","柳宗元《构法华寺西亭》"] +["积毁销金,沈忧作歌。","李白《雪谗诗赠友人》"] +["黎儿愤勇决,挺身负戈矢。","钱嶫《悯黎咏》"] +["飞花去,良宵长。","史达祖《寿楼春·寻春服感念》"] +["神武贵勿杀,不在斩获为。","钱嶫《悯黎咏》"] +["自怜冰脸不时宜。","苏轼《定风波·红梅》"] +["孤城回望苍烟合。","苏轼《醉落魄·离京口作》"] +["相逢一醉是前缘,风雨散、飘然何处?","苏轼《鹊桥仙·七夕》"] +["霜花强弄春颜色。","吴文英《玉楼春·和吴见山韵》"] +["尊前拟把归期说,欲语春容先惨咽。","欧阳修《玉楼春·尊前拟把归期说》"] +["晴日暖,淡烟浮。","仲殊《诉衷情·寒食》"] +["越明年秋,复寻前盟于白荷凉月间。","周密《齐天乐·清溪数点芙蓉雨》"] +["清明又近也,却天涯为客。","曹组《忆少年·年时酒伴》"] +["守节自誓,亲诲之学。","欧阳修《画地学书》"] +["萧关逢候骑,都护在燕然。","王维《使至塞上》"] +["黄云城边乌欲栖,归飞哑哑枝上啼。","李白《乌夜啼》"] +["恼人风味阿谁知?请君问取南楼月。","吕本中《踏莎行·雪似梅花》"] +["宝带金章,尊前茸帽风欹。","周密《高阳台·送陈君衡被召》"] +["阿房废址汉荒丘。","康与之《诉衷情令·长安怀古》"] +["长安少年游侠客,夜上戍楼看太白。","王维《陇头吟》"] +["风回云断雨初晴,返照湖边暖复明。","白居易《南湖早春》"] +["碧罗衣上蹙金绣,睹对对鸳鸯,空裛泪痕透。","欧阳炯《贺明朝·忆昔花间相见后》"] +["俯视但一气,焉能辨皇州。","杜甫《同诸公登慈恩寺塔》"] +["半肩风雨半肩柴,竹杖芒鞋破碧崖。","常慧《云居山咏二首》"] +["最怜轻负年时约。","张元干《满江红·自豫章阻风吴城山作》"] +["刘彻茂陵多滞骨,嬴政梓棺费鲍鱼。","李贺《苦昼短》"] +["凭寄还乡梦,殷勤入故园。","柳宗元《零陵早春》"] +["日边消息空沈沈。画眉楼上愁登临。","郑文妻《忆秦娥·花深深》"] +["迨我暇矣,饮此湑矣。","佚名《伐木》"] +["但数点红英,犹记西园凄婉。","詹玉《长亭怨慢·重过中庵故园》"] +["十二峰头秋草荒,冷烟寒月过瞿塘。","何景明《竹枝词·十二峰头秋草荒》"] +["当年何人佐祖龙?上蔡公子牵黄狗。","苏轼《石鼓歌》"] +["雨后双禽来占竹,秋深一蝶下寻花。","文同《北斋雨后》"] +["予独爱莲之出淤泥而不染,濯清涟而不妖","周敦颐《爱莲说》"] +["趁残鸦、飞过苍茫。","吴文英《夜合花·自鹤江入京泊葑门外有感》"] +["吾家洗砚池头树,个个花开淡墨痕。","王冕《墨梅》"] +["西得诸侯棹锦水,欲向何门趿珠履?","杜甫《短歌行赠王郎司直》"] +["无曰高高在上,陟降厥士,日监在兹。","佚名《周颂·敬之》"] +["男儿少为客,不辨是他乡。","李流芳《黄河夜泊》"] +["重到故乡交旧少,凄凉。却恐它乡胜故乡。","陆游《南乡子·归梦寄吴樯》"] +["此士难再得,吾行欲何求!","陶渊明《拟古九首》"] +["怅望梅花驿,凝情杜若洲。","范成大《南柯子·怅望梅花驿》"] +["移舟水溅差差绿,倚槛风摆柄柄香。","郑谷《莲叶》"] +["诗绛彩娇春,苍筠静锁,掩映夭姿凝露。","曹勋《夹竹桃花·咏题》"] +["更草草离筵,匆匆去路,愁满旌旗。","辛弃疾《木兰花慢·席上送张仲固帅兴元》"] +["鴥彼晨风,郁彼北林。","佚名《晨风》"] +["女子有行,远父母兄弟。","佚名《蝃蝀》"] +["穷途唯有泪,还望独潸然。","王勃《重别薛华》"] +["昨日出东城,拭探春情。","苏轼《浪淘沙·探春》"] +["花桑羯鼓玉方响,春风不敢生尘埃。","李清照《浯溪中兴颂诗和张文潜二首》"] +["日射纱窗风撼扉,香罗拭手春事违。","李商隐《日射》"] +["江作青罗带,山如碧玉篸。","韩愈《送桂州严大夫同用南字》"] +["一去麒麟阁,遂将朝市乖。","李白《书怀赠南陵常赞府》"] +["周旋多燕乐,门馆列车骑。","高适《效古赠崔二》"] +["别来怅、光阴容易,还又酴醿,牡丹开遍。","卢祖皋《倦寻芳·香泥垒燕》"] +["近来何处有吾愁,何处还知吾乐。","辛弃疾《念奴娇·赋雨岩》"] +["呜呼!楚虽三户能亡秦,岂有堂堂中国空无人!","陆游《金错刀行》"] +["豆苗虫促促,篱上花当屋。","温庭筠《烧歌》"] +["清香随风发,落日好鸟归。","李白《咏邻女东窗海石榴》"] +["佳节清明桃李笑,野田荒冢只生愁。","黄庭坚《清明》"] +["只恐夜深花睡去,故烧高烛照红妆。","苏轼《海棠》"] +["驾言陟东阜,望坟思纡轸。","潘安《悼亡诗三首》"] +["织妇何太忙,蚕经三卧行欲老。","元稹《织妇词》"] +["谤书盈箧不复辩,脱身来看江南山。","谢逸《送董元达》"] +["笑渐不闻声渐悄。","苏轼《蝶恋花·春景》"] +["聪明睿智,守之以愚","《太平御览·文部·卷六》"] +["征客近来音信断,不知何处寄寒衣?","张纮《闺怨 / 怨诗》"] +["春燕差池风散梅,开帏对景弄禽爵。","鲍照《拟行路难十八首》"] +["忽蒙白日回景光,直上青云生羽翼。","李白《驾去温泉后赠杨山人》"] +["遂令感激士,得建非常功。","高适《李云南征蛮诗》"] +["欲渡浣花溪,远梦轻无力。","纳兰性德《生查子·短焰剔残花》"] +["惜君只欲苦死留,富贵何如草头露?","杜甫《送孔巢父谢病归游江东兼呈李白》"] +["翠蛾轻敛意沉吟,沾襟,无人知此心。","孙光宪《河传·花落》"] +["虫兽犹知德,何况于士人。","曹植《薤露》"] +["路旁时卖故侯瓜,门前学种先生柳。","王维《老将行》"] +["梁鸿德耀会稽日,宁知此中乐事多。","李白《和卢侍御通塘曲》"] +["雁柱十三弦,一一春莺语。","欧阳修《生查子·含羞整翠鬟》"] +["孤魂游穷暮,飘摇安所依。","孔融《杂诗二首》"] +["兄弟阋于墙,外御其务。","佚名《常棣》"] +["行人断消息,春恨几裴回。","杨炯《梅花落》"] +["空相对,残红无寐,满村社鼓。","刘辰翁《永遇乐·璧月初晴》"] +["止为荼荈据,吹嘘对鼎立。","左思《娇女诗》"] +["美如玉,殊异乎公族。","佚名《汾沮洳》"] +["小荷才露尖尖角,早有蜻蜓立上头。","杨万里《小池》"] +["造化钟神秀,阴阳割昏晓。","杜甫《望岳》"] +["君不见冰上霜,表里阴且寒。","鲍照《拟行路难十八首》"] +["春风开紫殿,天乐下朱楼。","李白《宫中行乐词八首》"] +["嫣然幽谷,只愁又听啼鴃。","陈子龙《念奴娇·春雪咏兰》"] +["征师屯广武,分兵救朔方。","鲍照《代出自蓟北门行》"] +["晚来天欲雪,能饮一杯无?","白居易《问刘十九》"] +["后堂深、想费春工。","吴文英《高山流水·丁基仲侧室善丝桐赋咏晓达音吕备歌舞之妙》"] +["恍恍与之去,驾鸿凌紫冥。","李白《古风·其十九》"] +["莲步轻飞,迁客今朝始是归。","苏轼《减字木兰花·江南游女》"] +["沈郎多病不胜衣。","苏轼《浣溪沙·风压轻云贴水飞》"] +["桂林之一枝,昆山之片玉。","《晋书·列传·第二十二章》"] +["昔闻李供奉,长啸独登楼。","王世贞《登太白楼》"] +["无心者公,无我者明。","《格言联璧·处事类》"] +["古岩泉滴滴,幽谷鸟关关。","令狐楚《赋山 / 一七令·山》"] +["江南好,千钟美酒,一曲满庭芳。","苏轼《满庭芳·蜗角虚名》"] +["春风桃李花开日,秋雨梧桐叶落时。","白居易《长恨歌》"] +["扁舟载了,匆匆归去,今夜泊前溪。","姜夔《少年游·戏平甫》"] +["允恭克让,光被四表,格于上下。","《尚书·虞书·尧典》"] +["青蒲衔紫茸,长叶复从风。","佚名《拔蒲二首》"] +["成王不敢康,夙夜基命宥密。","佚名《周颂·昊天有成命》"] +["南昌仙人赵夫子,妙年历落青云士。","李白《当涂赵炎少府粉图山水歌》"] +["乌孙部落家乡远,逻娑沙尘哀怨生。","李颀《听董大弹胡笳声兼寄语弄房给事》"] +["那堪细把,旧约前欢重省。","柳永《倾杯·金风淡荡》"] +["输与五陵公子,此时梦绕花前。","纳兰性德《清平乐·发汉儿村题壁》"] +["冽彼下泉,浸彼苞稂。","佚名《下泉》"] +["空对着,山中高士晶莹雪;","曹雪芹《终身误》"] +["非为织作迟,君家妇难为!","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["夜醉长沙酒,晓行湘水春。","杜甫《发潭州》"] +["小舟飞棹去如梭,齐唱采菱歌。","苏轼《画堂春·寄子由》"] +["凡道字 重且舒 勿急疾 勿模糊","《弟子规·信》"] +["弯环正是愁眉样。","晏几道《蝶恋花·初捻霜纨生怅望》"] +["利人乎即为,不利人乎即止。","《墨子·32章 非乐(上)》"] +["获之挃挃,积之栗栗。","佚名《周颂·良耜》"] +["风毛雨血万人欢。","纳兰性德《于中好·谁道阴山行路难》"] +["王国富民,霸国富士;仅存之国,富大夫;亡道之国,富仓府","《说苑·政理》"] +["为客正当无雁处,故园谁道有书来。","曹松《南海旅次》"] +["福满须防有祸,凶多料必无争。","《增广贤文·下集》"] +["三日入厨下,洗手作羹汤。","王建《新嫁娘词三首》"] +["国将兴,必贵师而重傅,贵师而重傅,则法度存。","《荀子·大略》"] +["怅恍如或存,回惶忡惊惕。","潘安《悼亡诗三首》"] +["一朝天霜下,荣耀难久存。","李白《咏桂》"] +["庶曰式臧,覆出为恶。","佚名《雨无正》"] +["舞袖拂秋月,歌筵闻早鸿。","李白《鲁中送二从弟赴举之西京》"] +["巴童荡桨欹侧过,水鸡衔鱼来去飞。","杜甫《阆水歌》"] +["洞房昨夜停红烛,待晓堂前拜舅姑。","朱庆馀《近试上张籍水部 / 近试上张水部 / 闺意献张水部》"] +["月满蓬壶灿烂灯,与郎携手至端门。","佚名《鹧鸪天·月满蓬壶灿烂灯》"] +["入蔡缚贼献太庙,功无与让恩不訾。","李商隐《韩碑》"] +["手持三尺定山河,四海为家共饮和。","洪秀全《吟剑》"] +["借问游方士,焉测尘嚣外。","陶渊明《桃花源诗》"] +["云淡风轻近午天,傍花随柳过前川。","程颢《春日偶成·云淡风轻近午天》"] +["妲娥垂明珰,织女奉瑛琚。","佚名《艳歌》"] +["十分春易尽,一点情难改。","惠洪《千秋岁·半身屏外》"] +["一去麒麟阁,遂将朝市乖。","李白《书怀赠南陵常赞府》"] +["道人憔悴春窗底。闷损阑干愁不倚。","李清照《玉楼春·红酥肯放琼苞碎》"] +["天意怜幽草,人间重晚晴。","李商隐《晚晴》"] +["玉勒雕鞍游冶处,楼高不见章台路。","欧阳修《蝶恋花·庭院深深深几许》"] +["人之为言,苟亦无与。","佚名《采苓》"] +["吴酒一杯春竹叶,吴娃双舞醉芙蓉。","白居易《忆江南词三首》"] +["犹闻辞后主,不复卧南阳。","杜甫《武侯庙》"] +["春云吹散湘帘雨,絮黏蝴蝶飞还住。","纳兰性德《菩萨蛮·春云吹散湘帘雨》"] +["枯藤老树昏鸦,小桥流水人家,古道西风瘦马。","马致远《天净沙·秋思》"] +["世人笑我忒风颠,我咲世人看不穿。","唐寅《桃花庵歌》"] +["荐衡昔日知文举,乞火无人作蒯通。","杜牧《酬张祜处士见寄长句四韵》"] +["梦想旧山安在哉,为衔君命日迟回。","高适《封丘作》"] +["为问门前客,今朝几个来。","李適之《罢相作》"] +["日长篱落无人过,惟有蜻蜓蛱蝶飞。","范成大《四时田园杂兴·其二》"] +["蒙彼绉絺,是绁袢也。","佚名《君子偕老》"] +["沙明拳宿鹭,天阔退飞鸿。","潘大临《江间作四首·其三》"] +["三千里兮家未归,恨难禁兮仰天悲。","张翰《思吴江歌》"] +["日惨惨兮云冥冥,猩猩啼烟兮鬼啸雨。","李白《远别离》"] +["小怜玉体横陈夜,已报周师入晋阳。","李商隐《北齐二首》"] +["既有肥牡,以速诸舅。","佚名《伐木》"] +["少游醉卧古藤下,谁与愁眉唱一杯。","黄庭坚《寄贺方回》"] +["不知马骨伤寒水,惟见龙城起暮云。","王涯《塞下曲二首·其二》"] +["兄弟婚姻,无胥远矣。","佚名《角弓》"] +["破敌金城雷过耳,谈兵玉帐冰生颊。","辛弃疾《满江红·汉水东流》"] +["离愁秋水远,醉眼晓帆开。","张元干《临江仙·送宇文德和被召赴行在所》"] +["己之困辱宜忍,而在人则不可忍。","《菜根谭·概论》"] +["长卿牢落悲空舍,曼倩诙谐取自容。","李贺《南园十三首》"] +["东林送客处,月出白猿啼。","李白《别东林寺僧》"] +["江南春尽离肠断,苹满汀洲人未归。","寇准《江南春·波渺渺》"] +["兰皋新涨绿溶溶。","戴复古《木兰花慢·莺啼啼不尽》"] +["燕兵夜娖银胡觮,汉箭朝飞金仆姑。","辛弃疾《鹧鸪天·有客慨然谈功名因追念少年时事戏作》"] +["叹年华一瞬,人今千里,梦沉书远。","周邦彦《过秦楼·大石》"] +["玉堂虚,细雾垂珠佩,轻烟曳翠裾。","李珣《女冠子·春山夜静》"] +["菊花开,正归来。","马致远《拨不断·菊花开》"] +["暗香横路雪垂垂。","辛弃疾《江神子·赋梅寄余叔良》"] +["亲曾见,全胜宋玉,想像赋高唐。","苏轼《满庭芳·香叆雕盘》"] +["孤臣霜发三千丈,每岁烟花一万重。","陈与义《伤春》"] +["时载酒,调冰雪。","吴文英《满江红·翠幕深庭》"] +["糜灭岂不痛,愿与株荄连。","曹植《吁嗟篇》"] +["云雨无情难管领,任他别嫁楚襄王。","李群玉《赠人》"] +["此宜无罪,女反收之。","佚名《大雅·瞻卬》"] +["岩中响自答,溪里言弥静。","崔颢《入若耶溪》"] +["以兹误生理,独耻事干谒。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["疏影微香,下有幽人昼梦长。","苏轼《减字木兰花·双龙对起》"] +["八年十二月,五日雪纷纷。","白居易《村居苦寒》"] +["淡淡宫梅,也依然、点酥剪水。","陆游《月上海棠·斜阳废苑朱门闭》"] +["莫待春回,颠倒红英间绿苔。","苏轼《减字木兰花·莺初解语》"] +["我心惮暑,忧心如熏。","佚名《云汉》"] +["独自上层楼,楼外青山远。","程垓《卜算子·独自上层楼》"] +["轻则寡谋,骄则无礼。","《国语·周语·王孙满观秦师》"] +["奈何燕太子,尚使田生疑。","陈子昂《蓟丘览古赠卢居士藏用七首》"] +["海阔山遥,未知何处是潇湘。","柳永《玉蝴蝶·望处雨收云断》"] +["浮天水送无穷树,带雨云埋一半山。","辛弃疾《鹧鸪天·送人》"] +["客帆风正急,茜袖偎墙立。","孙光宪《菩萨蛮·木棉花映丛祠小》"] +["欲诉幽怀,转过回阑叩玉钗。","纳兰性德《减字木兰花·相逢不语》"] +["玉楼歌吹,声断已随风。","鹿虔扆《临江仙·金锁重门荒苑静》"] +["太上,不知有之;其次,亲而誉之;其次,畏之;其次,侮之。信不足焉,有不信焉。","《老子·道经·第十七章》"] +["浊酒一杯家万里,燕然未勒归无计。","范仲淹《渔家傲·秋思》"] +["泠泠七弦上,静听松风寒。","刘长卿《听弹琴》"] +["丛薄聚冻禽,狐狸啸枯株。","王冕《冀州道中》"] +["主人下马客在船,举酒欲饮无管弦。","白居易《琵琶行 / 琵琶引》"] +["应恨刘郎来又去。","苏轼《减字木兰花·送别》"] +["二矛重乔,河上乎逍遥。","佚名《清人》"] +["寒灰炙手,人重人轻。","苏轼《行香子·寓意》"] +["时闻先朝陵寝,有不忍言者。","陈子龙《唐多令·寒食》"] +["桂林风景异,秋似洛阳春。","宋之问《始安秋日》"] +["东风随春归,发我枝上花。","李白《落日忆山中》"] +["不向花边拚一醉,花不语,笑人痴。","王炎《江城子·癸酉春社》"] +["老小相依来就南,南人丰年自无食。","王安石《河北民》"] +["鳲鸠在桑,其子七兮。","佚名《鳲鸠》"] +["独自下层楼,楼下蛩声怨。","程垓《卜算子·独自上层楼》"] +["酒客爱秋蔬,山盘荐霜梨。","李白《寻鲁城北范居士失道落苍耳中见范置酒摘苍耳作》"] +["男儿爱后妇,女子重前夫。","辛延年《羽林郎》"] +["独向潭上酌,无人林下期。","岑参《还高冠潭口留别舍弟》"] +["天作高山,大王荒之。","佚名《周颂·天作》"] +["起舞闻鸡酒未醒,潮落秋江冷。","张元干《卜算子·风露湿行云》"] +["蒙彼绉絺,是绁袢也。","佚名《君子偕老》"] +["洞庭春溜满,平湖锦帆张。","阴铿《渡青草湖》"] +["又还是、宫烛分烟,奈愁里、匆匆换时节。","姜夔《琵琶仙·双桨来时》"] +["凡与敌战,三军必要得其地利,则可以寡敌众,以弱胜强。","《百战奇略·第四卷·地战》"] +["昔日长城战,咸言意气高。","王昌龄《塞下曲四首》"] +["巡边使客行应早,欲问平安无使来。","张籍《凉州词三首》"] +["绿树闻歌鸟,青楼见舞人。","李白《宫中行乐词八首》"] +["积学以储宝,酌理以富才","《文心雕龙·神思》"] +["桐宫放太甲,摄政无愧色。","李白《纪南陵题五松山》"] +["密云双凤,初破缕金团。","米芾《满庭芳·咏茶》"] +["燕雀岂知鸿鹄,貂蝉元出兜鍪。","辛弃疾《破阵子·掷地刘郎玉斗》"] +["今欲东入海,即将西去秦。","杜甫《奉赠韦左丞丈二十二韵》"] +["其人虽已没,千载有馀情。","陶渊明《咏荆轲》"] +["夫子何为者,栖栖一代中。","李隆基《经邹鲁祭孔子而叹之》"] +["酒斟时、须满十分。","苏轼《行香子·述怀》"] +["江干多是钓人居,柳陌菱塘一带疏。","王士祯《真州绝句》"] +["笑时犹带岭梅香。试问岭南应不好。","苏轼《定风波·南海归赠王定国侍人寓娘》"] +["虽现尘劳,心恒清净","《圆觉经·普觉菩萨》"] +["时人不识凌云木,直待凌云始道高。","杜荀鹤《小松》"] +["叹神游故国,花记前度。","刘辰翁《兰陵王·丙子送春》"] +["香冷金猊,被翻红浪,起来人未梳头。","李清照《凤凰台上忆吹箫·香冷金猊》"] +["远雁下平沙,嘹亮遗凄唳。","刘道著《湘江秋晓》"] +["与天一样白漫漫。","章谦亨《浪淘沙·云藏鹅湖山》"] +["楼台临绝岸,洲渚亘长天。","王勃《重别薛华》"] +["遍九阳、相将游冶。","柳永《柳初新·东郊向晓星杓亚》"] +["渐妆点亭台,参差佳树。","柳永《夜半乐·艳阳天气》"] +["谁道风流种,唬杀寻芳的蜜蜂。","王和卿《醉中天·咏大蝴蝶》"] +["古之人无斁,誉髦斯士。","佚名《思齐》"] +["夫赏者,所以喜众,令士忘死也,罚者,所以正乱,令民畏上也。","《孙膑兵法·威王问》"] +["酒欲醒时,兴在卢仝碗。","胡铨《醉落魄·辛未九月望和答庆符》"] +["青青陵上柏,磊磊涧中石。","佚名《青青陵上柏》"] +["不道只、暂时相聚。","纳兰性德《金缕曲·姜西溟言别赋此赠之》"] +["莫鼓琵琶江上曲,怕荻花枫叶俱凄怨。","刘过《贺新郎·老去相如倦》"] +["惊物换,叹星移。","吴文英《醉桃源·赠卢长笛》"] +["周王日沦惑,汉帝益嗟称。","鲍照《代白头吟》"] +["人言此是海门关,海眼无涯骇众观。","谢枋得《小孤山》"] +["若无水殿龙舟事,共禹论功不较多。","皮日休《汴河怀古二首》"] +["漫试著春衫,还思纤手,熏彻金猊烬冷。","张孝祥《转调二郎神·闷来弹鹊》"] +["想今宵、也对新月,过轻寒、何处小桥。","赵汝茪《恋绣衾·柳丝空有千万条》"] +["冰泉冷涩弦凝绝,凝绝不通声暂歇。","白居易《琵琶行 / 琵琶引》"] +["曼倩风流缘底事,当时。","苏轼《南乡子·有感》"] +["八年十二月,五日雪纷纷。","白居易《村居苦寒》"] +["不惜红罗裂,何论轻贱躯。","辛延年《羽林郎》"] +["渭水银河清,横天流不息。","李白《君子有所思行》"] +["应愁晚泊喧卑地,吹入沧溟始自由!","苏舜钦《和淮上遇便风》"] +["短景归秋,吟思又接愁边。","史达祖《玉蝴蝶·晚雨未摧宫树》"] +["鸬鹚西日照,晒翅满鱼梁。","杜甫《田舍》"] +["向晚骤、宝马雕鞍,醉襟惹、乱花飞絮。","万俟咏《三台·清明应制》"] +["燕燕轻盈,莺莺娇软,分明又向华胥见。","姜夔《踏莎行·自沔东来丁未元日至金陵江上感梦而作》"] +["簌簌衣巾落枣花,村南村北响缫车,牛衣古柳卖黄瓜。","苏轼《浣溪沙·簌簌衣巾落枣花》"] +["义典则弘,文约为美。","《文心雕龙·铭箴》"] +["恩怨尔汝来去,弹指泪和声。","苏轼《水调歌头·昵昵儿女语》"] +["万事莫贵于义。","《墨子·47章 贵义》"] +["君去日已远,郁结令人老。","徐干《室思》"] +["相逢不尽平生事,春思入琵琶。","刘迎《乌夜啼·离恨远萦杨柳》"] +["花映柳条,闲向绿萍池上。","温庭筠《酒泉子·花映柳条》"] +["叔子独千载,名与汉江流。","陆游《水调歌头·多景楼》"] +["先生杖屦无事,一日走千回。","辛弃疾《水调歌头·盟鸥》"] +["分我一杯羹,太皇乃汝翁。","李白《登广武古战场怀古》"] +["于皇来牟,将受厥明。","佚名《周颂·臣工》"] +["以权利合者,权利尽而交疏。","《史记·郑世家》"] +["故故坠金鞭,回头应眼穿。","牛峤《菩萨蛮·风帘燕舞莺啼柳》"] +["曷云其还?岁聿云莫。","佚名《小明》"] +["亢龙宾天群龙战,潜龙跃出飞龙现。","成鹫《仙城寒食歌·绍武陵》"] +["几回花下坐吹箫,银汉红墙入望遥。","黄景仁《绮怀》"] +["惟郢路之辽远兮,江与夏之不可涉。","屈原《哀郢》"] +["过时而不采,将随秋草萎。","佚名《冉冉孤生竹》"] +["怪见溪童出门望,雀声先我到山家。","刘因《山家·马蹄踏水乱明霞》"] +["天监在下,有命既集。","佚名《大明》"] +["学而不厌,诲人不倦。","《论语·述而篇》"] +["月离于毕,俾滂沱矣。","佚名《渐渐之石》"] +["东野不回头,有如寸筳撞巨钟。","韩愈《醉留东野》"] +["被西风吹尽,了无尘迹。","辛弃疾《满江红·江行和杨济翁韵》"] +["汜人最苦,纷痕深、几重愁靥。","吴文英《凄凉犯·重台水仙》"] +["名山发佳兴,清赏亦何穷?","李白《下寻阳城泛彭蠡寄黄判官》"] +["委委佗佗,如山如河,象服是宜。","佚名《君子偕老》"] +["当言而不言,尔之责也;言而不听,我之咎也。","《元史·列传·卷六》"] +["夕照临轩堕,栖鸟当我还。","柳宗元《构法华寺西亭》"] +["年华共,混同江水,流去几时回。","纳兰性德《满庭芳·堠雪翻鸦》"] +["江南好,千钟美酒,一曲满庭芳。","苏轼《满庭芳·蜗角虚名》"] +["念羁情、游荡随风,化为轻絮。","吴文英《莺啼序·春晚感怀》"] +["重重叠叠上瑶台,几度呼童扫不开。","苏轼《花影》"] +["谈笑里,定齐鲁。","刘克庄《贺新郎·送陈真州子华》"] +["涵养冲虚,便是身世学问。省除烦恼,何等心性安和!","《格言联璧·存养类》"] +["当年得意如芳草。日日春风好。","辛弃疾《虞美人·赋虞美人草》"] +["但愿老死花酒间,不愿鞠躬车马前。","唐寅《桃花庵歌》"] +["仁之实,事亲是也;义之实,从兄是也。","《孟子·离娄章句上·第二十七节》"] +["溪云初起日沉阁,山雨欲来风满楼。","许浑《咸阳城东楼 / 咸阳城西楼晚眺 / 西门》"] +["四牡彭彭,王事傍傍。","佚名《北山》"] +["惟有今宵,皓彩皆同普。","郭应祥《醉落魄·丙寅中秋》"] +["鼎食非吾事,云仙尝我期。","张九龄《感遇十二首》"] +["凤皇翼其承旗兮,高翱翔之翼翼。","屈原《离骚》"] +["何以致契阔?绕腕双跳脱。","繁钦《定情诗》"] +["酒醒梦回愁几许。","苏轼《谒金门·秋感》"] +["积善在身,犹长日加益,而人不知也;积恶在身,犹火之销膏,而人不见也。","《汉书·传·董仲舒传》"] +["官军又取淮西贼,此贼亦除天下宁。","元稹《连昌宫词》"] +["绿树碧檐相掩映,无人知道外边寒。","吴融《华清宫二首》"] +["画梁时拂歌尘散。","陈尧佐《踏莎行·二社良辰》"] +["石上水潺潺,流入青溪去。","吴伟业《生查子·旅思》"] +["月光浸水水浸天,一派空明互回荡。","查慎行《中秋夜洞庭湖对月歌》"] +["中有一人字太真,雪肤花貌参差是。","白居易《长恨歌》"] +["槛菊萧疏,井梧零乱,惹残烟。","柳永《戚氏·晚秋天》"] +["稻云不雨不多黄,荞麦空花早着霜。","杨万里《悯农》"] +["醉中浑不记,归路月黄昏。","辛弃疾《临江仙·探梅》"] +["三十年前此地,父兄持我东西。","王安石《题西太一宫壁二首》"] +["远戍十年临的博,壮图万里战皋兰。","陆游《书愤二首》"] +["墨池飞出北溟鱼,笔锋杀尽中山兔。","李白《草书歌行》"] +["起来携素手,庭户无声,时见疏星渡河汉。","苏轼《洞仙歌·冰肌玉骨》"] +["山回路转不见君,雪上空留马行处。","岑参《白雪歌送武判官归京》"] +["酒酣应对燕山雪,正冰河月冻,晓陇云飞。","周密《高阳台·送陈君衡被召》"] +["零落江南不自由。两绸缪。料得吟鸾夜夜愁。","姜夔《忆王孙·番阳彭氏小楼作》"] +["既往既来,使我心疚。","佚名《大东》"] +["量入以为出","《礼记·王制》"] +["夜窗蔼芳气,幽卧知相亲。","柳宗元《戏题阶前芍药》"] +["但茫茫暮霭,目断武陵溪,往事难追。","韩元吉《六州歌头·东风著意》"] +["似佳人、兼合明光。","苏轼《占春芳·红杏了》"] +["棘心夭夭,母氏劬劳。","佚名《凯风》"] +["烟隔断、晴波远岫。","吴文英《夜游宫·春语莺迷翠柳》"] +["更重重、龙绡衬著。","辛弃疾《瑞鹤仙·赋梅》"] +["吹不断,斜阳依旧,茫茫禹迹都无。","辛弃疾《汉宫春·会稽秋风亭观雨》"] +["玉窗挑锦倦,惊响檐铁。","厉鹗《齐天乐·秋声馆赋秋声》"] +["喧啾百鸟群,忽见孤凤凰。","韩愈《听颖师弹琴》"] +["欲为圣明除弊事,肯将衰朽惜残年。","韩愈《左迁至蓝关示侄孙湘》"] +["元戎号令严,人马亦轻肥。","高适《杂曲歌辞·蓟门行五首》"] +["之子期宿来,孤琴候萝径。","孟浩然《宿业师山房期丁大不至 / 宿业师山房待丁大不至》"] +["济人然后拂衣去,肯作徒尔一男儿!","王维《不遇咏》"] +["风雨牢愁无著处,那更寒蛩四壁。","文天祥《酹江月·和友驿中言别》"] +["泛舟舻,齐棹惊。","鲍照《春日行》"] +["岂知泥滓贱,只见玉堂开。","张九龄《咏燕 / 归燕诗》"] +["弃置勿复陈,客子常畏人。","曹丕《杂诗二首》"] +["濯锦江边两岸花,春风吹浪正淘沙。","刘禹锡《浪淘沙九首》"] +["斑骓只系垂杨岸,何处西南任好风。","李商隐《无题·凤尾香罗薄几重》"] +["君子来朝,言观其旂。","佚名《采菽》"] +["业力甚大,能敌须弥,能深巨海,能障圣道。","《地藏经·地狱名号》"] +["三杯通大道,一醉解千愁。","《增广贤文·上集》"] +["秋坟鬼唱鲍家诗,恨血千年土中碧。","李贺《秋来》"] +["御陌青门拂地垂,千条金缕万条丝。","刘禹锡《杨柳枝词九首》"] +["浙江轻浪去悠悠,望海楼吹望海愁。","无名氏《杂诗》"] +["重华一去宁复得,天下纷纷经几秦。","王安石《桃源行》"] +["绿鬓朱颜重见两衰翁。","秦观《江城子·南来飞燕北归鸿》"] +["海内贤豪青云客,就中与君心莫逆。","李白《忆旧游寄谯郡元参军》"] +["一庭之内,自有至乐;六经以外,别无奇书。","《格言联璧·学问类》"] +["孤馆灯青,野店鸡号,旅枕梦残。","苏轼《沁园春·孤馆灯青》"] +["高帝子孙尽隆准,龙种自与常人殊。","杜甫《哀王孙》"] +["傥归免相失,见日敢辞迟。","杜甫《遣兴》"] +["官军又取淮西贼,此贼亦除天下宁。","元稹《连昌宫词》"] +["顾瞻周道,中心怛兮。","佚名《匪风》"] +["不如留取,十分春态,付与明年。","苏轼《雨中花·夜行船》"] +["别来几向梦中看。","潘阆《酒泉子·长忆观潮》"] +["吏怒反索金,黎民那有此。","钱嶫《悯黎咏》"] +["晋室昔横溃,永嘉遂南奔。","李白《登金陵冶城西北谢安墩》"] +["顺天者昌,逆天者亡。母逆天道,则不失所守。","《黄帝四经·十大经·姓争》"] +["富贵浮云,俯仰流年二十春。","欧阳修《采桑子·平生为爱西湖好》"] +["可奈别离何,明朝杨柳多。","吴锡麒《菩萨蛮·春波软荡红楼水》"] +["况有红妆,楚腰越艳,一笑千金何啻。","柳永《长寿乐·繁红嫩翠》"] +["满坐迷魂酒半醺。","欧阳修《减字木兰花·歌檀敛袂》"] +["细草愁烟,幽花怯露。","晏殊《踏莎行·细草愁烟》"] +["银瓶乍破水浆迸,铁骑突出刀枪鸣。","白居易《琵琶行 / 琵琶引》"] +["目极魂断望不见,猿啼三声泪滴衣。","孟郊《巫山曲》"] +["愁鬓点新霜,曾是朝衣染御香。","陆游《南乡子·归梦寄吴樯》"] +["用功于内者,必于外无所求","《围炉夜话·第一八八则》"] +["扬云霓之晻蔼兮,鸣玉鸾之啾啾。","屈原《离骚》"] +["梦断香消四十年,沈园柳老不吹绵。","陆游《沈园二首》"] +["青袍白马更何有,后汉今周喜再昌。","杜甫《洗兵马》"] +["铁锁横江,锦帆冲浪,孙郎良苦。","朱敦儒《水龙吟·放船千里凌波去》"] +["有约不来过夜半,闲敲棋子落灯花。","赵师秀《约客》"] +["醉别复几日,登临遍池台。","李白《鲁郡东石门送杜二甫》"] +["江山留胜迹,我辈复登临。","孟浩然《与诸子登岘山》"] +["几砚昔年游,于今成十秋。","司马光《送龚章判官之卫州·新及第》"] +["”兔从狗窦入,雉从梁上飞。","佚名《十五从军征》"] +["既见复关,载笑载言。","佚名《氓》"] +["缠绵思尽抽残茧,宛转心伤剥后蕉。","黄景仁《绮怀》"] +["将顺其美,匡救其恶,故上下能哙亲也","《史记·管晏列传》"] +["今我来思,雨雪载途。","佚名《出车》"] +["正大夫离居,莫知我勚。","佚名《雨无正》"] +["桑柘影斜春社散,家家扶得醉人归。","王驾《社日》"] +["旌蔽日兮敌若云,矢交坠兮士争先。","屈原《国殇》"] +["童孙未解供耕织,也傍桑阴学种瓜。","范成大《夏日田园杂兴·其七》"] +["百龄何荡漾,万化相推迁。","李白《郢门秋怀》"] +["柳花深巷午鸡声,桑叶尖新绿未成。","范成大《春日田园杂兴》"] +["唤取骑鲸客,挝鼓过银山。","元好问《水调歌头·赋三门津》"] +["啜苦咽甘,茶也。","《茶经·五之煮》"] +["为问暗香闲艳,也相思、万点付啼痕。","孔夷《南浦·旅怀》"] +["受小共大共,为下国骏厖。","佚名《商颂·长发》"] +["终日望君君不至,举头闻鹊喜。","冯延巳《谒金门·风乍起》"] +["浮云何洋洋,愿因通我词。","徐干《室思》"] +["纵饶夺得林胡塞,碛地桑麻种不生。","陈陶《陇西行四首》"] +["过海云帆来似箭。","苏轼《减字木兰花·荔枝》"] +["留得罗襟前日泪,弹与征鸿。","李清照《浪淘沙·帘外五更风》"] +["不似海棠阴下,按《凉州》时节。","魏夫人《好事近·雨后晓寒轻》"] +["借问苦心爱者谁,后有韦讽前支遁。","杜甫《韦讽录事宅观曹将军画马图》"] +["青冥却垂翅,蹭蹬无纵鳞。","杜甫《奉赠韦左丞丈二十二韵》"] +["爱之不以道,适所以害之也。","《资治通鉴·晋纪十八》"] +["山川萧条极边土,胡骑凭陵杂风雨。","高适《燕歌行》"] +["是处红衰翠减,苒苒物华休。","柳永《八声甘州·对潇潇暮雨洒江天》"] +["鬓丝日日添白头,榴锦年年照眼明;","殷尧藩《端午日》"] +["雪里已知春信至。寒梅点缀琼枝腻。","李清照《渔家傲·雪里已知春信至》"] +["闲云收尽,海光天影相接。","朱敦儒《念奴娇·插天翠柳》"] +["百川东到海,何时复西归?","佚名《长歌行》"] +["又入铜驼,遍旧家门巷,首询声价。","史达祖《三姝媚·烟光摇缥瓦》"] +["心应弦,手应鼓。","白居易《胡旋女》"] +["解落三秋叶,能开二月花。","李峤《风》"] +["商之先后,受命不殆,在武丁孙子。","佚名《商颂·玄鸟》"] +["虎拜稽首,对扬王休。","佚名《大雅·江汉》"] +["胡人山下哭,胡马海边死。","高适《宋中送族侄式颜》"] +["长枪江米熟,小树枣花春。","李贺《始为奉礼忆昌谷山居》"] +["载鸱夷、烟江一舸。","吴文英《水龙吟·送万信州》"] +["玉环妖血无人扫,渔阳马厌长安草。","张耒《读中兴碑 / 读中兴颂碑》"] +["幽兰露,如啼眼。","李贺《苏小小墓》"] +["雪月花中过洞庭。","赵溍《吴山青·金璞明》"] +["吾非济代人,且隐屏风叠。","李白《赠王判官时余归隐居庐山屏风叠》"] +["咸阳市中叹黄犬,何如月下倾金罍?","李白《襄阳歌》"] +["到如今、只有蒋山青,秦淮碧!","萨都剌《满江红·金陵怀古》"] +["勿营华屋,勿谋良田。","《朱子家训·全文》"] +["春草细还生,春雏养渐成。","揭傒斯《画鸭》"] +["寿考维祺,以介景福。","佚名《行苇》"] +["漏催水咽玉蟾蜍,卫娘发薄不胜梳。","李贺《浩歌》"] +["停画桡、两两舟人语。","柳永《安公子·远岸收残雨》"] +["濯锦江边两岸花,春风吹浪正淘沙。","刘禹锡《浪淘沙九首》"] +["愧无江海量,偃蹇在君门。","李白《咏山樽二首》"] +["席帽聊萧,偶经过、信陵祠下。","陈维崧《满江红·秋日经信陵君祠》"] +["仙人抚我顶,结发受长生。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["惟翁醉中知其天。","苏轼《醉翁操·琅然》"] +["日斜吾事毕,一笑向杯盘。","陆游《乙卯重五诗》"] +["悠悠早秋意,生此幽闲中。","白居易《别元九后咏所怀》"] +["济南春好雪初晴。","苏轼《阳关曲·答李公择》"] +["尔之亟行,遑脂尔车。","佚名《何人斯》"] +["春天和,白日暖。","李白《雉朝飞》"] +["锦里开芳宴,兰缸艳早年。","卢照邻《十五夜观灯》"] +["长门西去昭阳殿。","王国维《蝶恋花·月到东南秋正半》"] +["金粟堆前木已拱,瞿唐石城草萧瑟。","杜甫《观公孙大娘弟子舞剑器行》"] +["秋昏塞外云,雾暗关山月。","杨广《饮马长城窟行》"] +["薄污我私,薄浣我衣。","佚名《葛覃》"] +["噫吁嚱,危乎高哉!","李白《蜀道难》"] +["抗声促遗诏,定命由陈辞。","柳宗元《哭连州凌员外司马》"] +["朝云信断知何处?应作襄王春梦去。","晏几道《木兰花·秋千院落重帘暮》"] +["奕奕梁山,维禹甸之,有倬其道。","佚名《韩奕》"] +["徐方既同,天子之功。","佚名《大雅·常武》"] +["灵均去后楚山空,澧阳兰芷无颜色。","苏轼《归朝欢·我梦扁舟浮震泽》"] +["烈火张天照云海,周瑜于此破曹公。","李白《赤壁歌送别》"] +["梅花雪,梨花月,总相思。","张惠言《相见欢·年年负却花期》"] +["几代生涯傍海涯,两三间屋盖芦花。","郭震《宿渔家》"] +["文章憎命达,魑魅喜人过。","杜甫《天末怀李白》"] +["居常土思兮心内伤,愿为黄鹄兮归故乡。","刘细君《悲秋歌》"] +["双双瞻客上,一一背人飞。","杜甫《归雁二首》"] +["维此惠君,民人所瞻。","佚名《桑柔》"] +["疏钟已应晚来风。","李清照《浣溪沙·莫许杯深琥珀浓》"] +["夫尚贤者,政之本也。","《墨子·08章 尚贤(上)》"] +["压鬓钗梁金凤小,低头只是闲烦恼。","谭献《蝶恋花·庭院深深人悄悄》"] +["尽挹西江,细斟北斗,万象为宾客。","张孝祥《念奴娇·过洞庭》"] +["酒瓮琴书伴病身,熟谙时事乐于贫。","杜荀鹤《自叙》"] +["梅实迎时雨,苍茫值晚春。","柳宗元《梅雨》"] +["单于莫近塞,都护欲临边。","崔颢《送单于裴都护赴西河》"] +["沉吟此事泪满衣,黄金买醉未能归。","李白《梁园吟》"] +["不解藏踪迹,浮萍一道开。","白居易《池上二绝》"] +["尚留顾藉,滞东风、未肯雪轻飘。","周端臣《木兰花慢·送人之官九华》"] +["蛮姜豆蔻相思味。算却在、春风舌底。","吴文英《杏花天·咏汤》"] +["敛尽春山羞不语。","苏轼《蝶恋花·记得画屏初会遇》"] +["断无蜂蝶慕幽香,红衣脱尽芳心苦。","贺铸《芳心苦·杨柳回塘》"] +["老死阡陌间,何因扬清芬。","李白《赠何七判官昌浩》"] +["火照西宫知夜饮,分明复道奉恩时。","王昌龄《长信怨》"] +["秋杪,道中荷花无复存者。","陈与义《虞美人·扁舟三日秋塘路》"] +["亭高出鸟外,客到与云齐。","岑参《早秋与诸子登虢州西亭观眺》"] +["前年伐月支,城上没全师。","张籍《没蕃故人》"] +["当年失行乐,老去徒伤悲。","李白《相逢行二首》"] +["日长睡起无情思,闲看儿童捉柳花。","杨万里《闲居初夏午睡起》"] +["御史府中乌夜啼,廷尉门前雀欲栖。","卢照邻《长安古意》"] +["台星再朗,天网重恢。","李白《上崔相百忧章》"] +["我李百万叶,柯条布中州。","李白《赠清漳明府侄聿》"] +["偏奇处、当庭月暗,吐焰为虹。","赵长卿《潇湘夜雨·灯词》"] +["微官何足道,爱客且相携。","岑参《早秋与诸子登虢州西亭观眺》"] +["中秋谁与共孤光。把盏凄然北望。","苏轼《西江月·世事一场大梦》"] +["休别有鱼处,莫恋浅滩头。","《增广贤文·上集》"] +["揄扬九重万乘主,谑浪赤墀青琐贤。","李白《玉壶吟》"] +["却愁宴罢青娥散,扬子江头月半斜。","韦庄《陪金陵府相中堂夜宴》"] +["跑沙跑雪独嘶,东望西望路迷。","韦应物《调笑令·胡马》"] +["后夜独怜回首处。","杨炎正《蝶恋花·别范南伯》"] +["何用别寻方外去,人间亦自有丹丘。","韩翃《同题仙游观》"] +["牛渚西江夜,青天无片云。","李白《夜泊牛渚怀古》"] +["得意犹堪夸世俗,诏黄新湿字如鸦。","苏轼《和董传留别》"] +["雨后晓寒轻,花外早莺啼歇。","魏夫人《好事近·雨后晓寒轻》"] +["唯有相思似春色,江南江北送君归。","王维《送沈子归江东 / 送沈子福之江东》"] +["铁衣远戍辛勤久,玉箸应啼别离后。","高适《燕歌行》"] +["寒窗梦里,犹记经行旧时路。","张炎《月下笛·万里孤云》"] +["萧萧散发到天明。","张元干《浣溪沙·山绕平湖波撼城》"] +["外人不见见应笑,天宝末年时世妆。","白居易《上阳白发人》"] +["不堪吏人妇,岂合令郎君?","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["二十四桥,颇有杜书记否。","刘辰翁《桂枝香·吹箫人去》"] +["过尽千帆皆不是,斜晖脉脉水悠悠。","温庭筠《望江南·梳洗罢》"] +["竹实满秋浦,凤来何苦饥。","李白《赠柳圆》"] +["海棠未雨,梨花先雪,一半春休。","王雱《眼儿媚·杨柳丝丝弄轻柔》"] +["薄躯信无庸,锁屑剧斗筲。","柳宗元《游朝阳岩遂登西亭二十韵》"] +["窃不自聊而原忠兮,或黕点而汙之。","宋玉《九辩》"] +["谁从穆公?子车针虎。","佚名《黄鸟》"] +["高山安可仰,徒此揖清芬。","李白《赠孟浩然》"] +["缟衣茹藘,聊可与娱。","佚名《出其东门》"] +["独向潭上酌,无人林下期。","岑参《还高冠潭口留别舍弟》"] +["”此语余深爱之。","姜夔《长亭怨慢·渐吹尽》"] +["苍苍森八桂,兹地在湘南。","韩愈《送桂州严大夫同用南字》"] +["成王不敢康,夙夜基命宥密。","佚名《周颂·昊天有成命》"] +["寄书长不达,况乃未休兵。","杜甫《月夜忆舍弟》"] +["坎其击鼓,宛丘之下。","佚名《宛丘》"] +["春色无情故,幽居亦见过。","刘禹锡《岁夜咏怀》"] +["刁斗皆催晓,蟾蜍且自倾。","杜甫《八月十五夜月二首》"] +["眼边江舸何匆促,未待安流逆浪归。","杜甫《雨不绝》"] +["镜中已觉星星误。人不负春春自负。","辛弃疾《玉楼春·风前欲劝春光住》"] +["维申及甫,维周之翰。","佚名《崧高》"] +["惜我时不遇,适与飘风会。","曹丕《杂诗二首》"] +["翘翘错薪,言刈其楚。","佚名《汉广》"] +["十里鱼山断处,留一抹、枣林红。","朱彝尊《霜天晓角·晚次东阿》"] +["闲坐悲君亦自悲,百年多是几多时。","元稹《遣悲怀三首·其三》"] +["纵使长条似旧垂,也应攀折他人手。","韩翃《章台柳·寄柳氏》"] +["弄篙莫溅水,畏湿红莲衣。","王维《皇甫岳云溪杂题五首·莲花坞》"] +["孤灯寒照雨,深竹暗浮烟。","司空曙《云阳馆与韩绅宿别》"] +["临事须替别人想,论人先将自己想。","《格言联璧·接物类》"] +["彤庭所分帛,本自寒女出。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["入牖千重碎,迎风一半斜。","李世民《望雪》"] +["入寻常、巷陌人家,相对如说兴亡,斜阳里。","周邦彦《西河·大石金陵》"] +["众人贵苟得,欲语羞雷同。","杜甫《前出塞九首》"] +["佳境宜缓棹,清辉能留客。","李白《泾溪南蓝山下有落星潭可以卜筑余泊舟石上寄何判官昌浩》"] +["秩秩大猷,圣人莫之。","佚名《巧言》"] +["一朝将聘茂陵女,文君因赠白头吟。","李白《白头吟》"] +["春未老,风细柳斜斜。","苏轼《望江南·超然台作》"] +["有时五峰气,散风如飞霜。","杜甫《望岳三首·其三》"] +["但愿苍生俱饱暖,不辞辛苦出山林。","于谦《咏煤炭》"] +["回车驾言迈,悠悠涉长道。","佚名《回车驾言迈》"] +["四坐楚囚悲,不忧社稷倾。","李白《金陵新亭》"] +["九泉莫叹三光隔,又送文星入夜台。","崔珏《哭李商隐》"] +["匪我愆期,子无良媒。","佚名《氓》"] +["下贫则上贫,下富则上富。","《荀子·富国》"] +["晨光出照屋梁明,初打开门鼓一声。","白居易《早兴》"] +["李陵没胡沙,苏武还汉家。","李白《千里思》"] +["碧纱笼绦节,黄藕冠浓云。","孙光宪《女冠子·淡花瘦玉》"] +["今夜故人来不来,教人立尽梧桐影。","吕岩《梧桐影·落日斜》"] +["算春长不老,人愁春老,愁只是、人间有。","晁补之《水龙吟·次歆林圣予惜春》"] +["可怜九月初三夜,露似真珠月似弓。","白居易《暮江吟》"] +["花谷依然,秀靥偷春小桃李。","吴文英《荔枝香近·送人游南徐》"] +["古堰对河壖,长林出淇口。","高适《自淇涉黄河途中作十三首》"] +["乐只君子,万寿无期。","佚名《南山有台》"] +["绿波浸叶满浓光,细束龙髯铰刀剪。","李贺《五粒小松歌》"] +["其维哲人,告之话言,顺德之行。","佚名《抑》"] +["铜雀春情,金人秋泪,此恨凭谁雪。","邓剡《酹江月·驿中言别友人》"] +["鳌山耸,喧天萧鼓。","柳永《迎新春·嶰管变青律》"] +["中兴当时颂大唐,大唐家国天为昌。","杨万里《浯溪摩崖怀古》"] +["明朝驿使发,一夜絮征袍。","李白《子夜吴歌·冬歌》"] +["安上在于悦下,为己存乎利人。","《晋书·列传·第二十四章》"] +["纨扇婵娟素月,纱巾缥缈轻烟。","陆游《乌夜啼·纨扇婵娟素月》"] +["飞雪过江来,船在赤栏桥侧。","吕渭老《好事近·飞雪过江来》"] +["倚阑凝望,独立渔翁满江雪。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["缆侵堤柳系,幔宛浪花浮。","杜甫《陪诸贵公子丈八沟携妓纳凉晚际遇雨二首》"] +["若到江南赶上春,千万和春住。","王观《卜算子·送鲍浩然之浙东》"] +["奇踪隐五百,一朝敞神界。","陶渊明《桃花源诗》"] +["鹔鹴换美酒,舞衣罢雕龙。","李白《怨歌行》"] +["一夜冰澌满玉壶。","石孝友《鹧鸪天·一夜冰澌满玉壶》"] +["恹恹,满院杨花不卷帘。","孙道绚《南乡子·春闺》"] +["辘辘车声如水去。","蒋氏女《减字木兰花·题雄州驿》"] +["君子乐胥,受天之祜。","佚名《桑扈》"] +["初月未终夕,边烽不过秦。","贾岛《暮过山村》"] +["狂风吹古月,窃弄章华台。","李白《司马将军歌》"] +["与余问答既有以,感时抚事增惋伤。","杜甫《观公孙大娘弟子舞剑器行》"] +["北山白云里,隐者自怡悦。","孟浩然《秋登兰山寄张五》"] +["长江悲已滞,万里念将归。","王勃《山中》"] +["恸哭兮远望,见苍梧之深山。","李白《远别离》"] +["天子好美女,夫妇不成双。","曹邺《捕渔谣》"] +["莫不率从,鲁侯之功。","佚名《鲁颂·閟宫》"] +["伊吕两衰翁,历遍穷通。","王安石《浪淘沙令·伊吕两衰翁》"] +["不用裁为鸣凤管,不须截作钓鱼竿。","白居易《题李次云窗竹》"] +["忆共锦衾无半缝,郎似桐花,妾似桐花凤。","王士祯《蝶恋花·和漱玉词》"] +["儿童相见不相识,笑问客从何处来。","贺知章《回乡偶书二首·其一》"] +["宁知傍淇水,騕褭黄金羁。","常建《春词二首》"] +["到头禾黍属他人,不知何处抛妻子。","张碧《农父》"] +["朝出沙头日正红,晚来云起半江中。","张潮《采莲词》"] +["知己一人谁是?已矣。","纳兰性德《荷叶杯·知己一人谁是》"] +["今孝子之为亲度也,将奈何哉?","《墨子·22章 节用(下)》"] +["竭兹筋力事,持用穷岁年。","柳宗元《田家三首》"] +["四牡骙骙,旟旐有翩。","佚名《桑柔》"] +["厩中皆肉马,不解上青天。","李贺《马诗二十三首》"] +["驰晖不可接,何况隔两乡?","谢朓《暂使下都夜发新林至京邑赠西府同僚》"] +["乍可狂歌草泽中,宁堪作吏风尘下?","高适《封丘作》"] +["晴浦晚风寒,青山玉骨瘦。","董以宁《卜算子·雪江晴月(回文,倒读《巫山一段云》)》"] +["我欲东归,害梁不为?","佚名《巫山高》"] +["欲说当年,望湖楼下,水与云宽窄。","辛弃疾《念奴娇·西湖和人韵》"] +["于嗟鸠兮!无食桑葚。","佚名《氓》"] +["青云教绾头上髻,明月与作耳边珰。","李贺《大堤曲》"] +["君从万里使,闻已到瓜州。","岑参《武威春暮闻宇文判官西使还已到晋昌》"] +["下民之孽,匪降自天。","佚名《十月之交》"] +["记玉关踏雪事清游,寒气脆貂裘。","张炎《八声甘州·记玉关踏雪事清游》"] +["蜀道之难,难于上青天,侧身西望长咨嗟!","李白《蜀道难》"] +["窗外日光弹指过,席间花影坐前移。","《水浒传·第二回》"] +["弱植不盈尺,远意驻蓬赢。","柳宗元《新植海石榴》"] +["雪沫乳花浮午盏,蓼茸蒿笋试春盘。","苏轼《浣溪沙·细雨斜风作晓寒》"] +["居常土思兮心内伤,愿为黄鹄兮归故乡。","刘细君《悲秋歌》"] +["为春憔悴留春住,那禁半霎催归雨。","纳兰性德《菩萨蛮·为春憔悴留春住》"] +["可爱一天风物,遍倚阑干十二,宇宙若萍浮。","米芾《水调歌头·中秋》"] +["锦衾不复襞,罗衣谁再缝。","王勃《铜雀妓二首》"] +["控弦破左的,右发摧月支。","曹植《白马篇》"] +["东风急,惜别花时手频执,罗帏愁独入。","牛峤《望江怨·东风急》"] +["人间俯仰,悲欢何限,团圆如故。","刘辰翁《桂枝香·吹箫人去》"] +["上人学苦空,百念已灰冷。","苏轼《送参寥师》"] +["情人怨遥夜,竟夕起相思。","张九龄《望月怀远》"] +["洛阳钟鼓至,车马系迟回。","杜审言《夏日过郑七山斋》"] +["拟泛烟中片叶,但两湖佳处,任风吹泊。","徐灿《念奴娇·西湖雨感次素庵韵》"] +["归来酌美酒,挟瑟上高堂。","宋子侯《董娇饶》"] +["流水不腐,户枢不蠹","《吕氏春秋·纪·季春纪》"] +["端守宫闱地,寒烟朝暮平。","韦嗣立《酬崔光禄冬日述怀赠答》"] +["却嫌脂粉污颜色,淡扫蛾眉朝至尊。","张祜《集灵台·其二》"] +["强整帽檐欹侧,曾经向、天涯搔首。","潘希白《大有·九日》"] +["未尽一尊先掩泪,歌声半带清悲。","苏轼《临江仙·冬夜夜寒冰合井》"] +["物性犹如此,人事亦宜然。","白居易《京兆府栽莲》"] +["莫肯下遗,式居娄骄。","佚名《角弓》"] +["疾威上帝,其命多辟。","佚名《荡》"] +["采薇采薇,薇亦柔止。","佚名《采薇》"] +["银床淅沥青梧老,屧粉秋蛩扫。","纳兰性德《虞美人·银床淅沥青梧老》"] +["万户千门成野草,只缘一曲后庭花。","刘禹锡《台城》"] +["之子合逸趣,而我钦清芬。","李白《题元丹丘颍阳山居》"] +["君安游兮西入秦,愿为影兮随君身。","傅玄《车遥遥篇》"] +["天生丽质难自弃,一朝选在君王侧。","白居易《长恨歌》"] +["莫为惜花惆怅对东风。","王炎《南柯子·山冥云阴重》"] +["昊天已威,予慎无罪。","佚名《巧言》"] +["日往菲薇,月来扶疏。","左思《三都赋》"] +["谁谓含愁独不见,更教明月照流黄。","沈佺期《古意呈补阙乔知之 / 古意 / 独不见》"] +["开尊待月,掩箔披风,依然灯火扬州。","秦观《长相思·铁瓮城高》"] +["眼枯即见骨,天地终无情!","杜甫《新安吏》"] +["少壮能几时,鬓发各已苍。","杜甫《赠卫八处士》"] +["萧萧渐积,纷纷犹坠,门荒径悄。","王沂孙《水龙吟·落叶》"] +["过目成诵,出口成章。","《警世通言·卷三》"] +["细雨南楼,香密锦温曾醉。","吴文英《荔枝香近·送人游南徐》"] +["箫鼓喧,人影参差,满路飘香麝。","周邦彦《解语花·上元》"] +["将军纵博场场胜,赌得单于貂鼠袍。","岑参《赵将军歌》"] +["楚天晚,白苹烟尽处,红蓼水边头。","张耒《风流子·木叶亭皋下》"] +["夫为将之道,必顺天、因时、依人以立胜也。","《将苑·卷一·智用》"] +["土花曾染湘娥黛,铅泪难消。","纳兰性德《采桑子·土花曾染湘娥黛》"] +["槛内群芳芽未吐,早已回春。","苏轼《浪淘沙·探春》"] +["黄鸟黄鸟,无集于穀,无啄我粟。","佚名《小雅·黄鸟》"] +["素衣莫起风尘叹,犹及清明可到家。","陆游《临安春雨初霁》"] +["管甚谁家兴废谁成败,陋巷箪瓢亦乐哉。","宋方壶《山坡羊·道情》"] +["弊裘耻妻嫂,长剑托交亲。","李白《赠友人三首》"] +["楞伽堆案前,楚辞系肘后。","李贺《赠陈商》"] +["既至家人尽,又复无中外。","蔡琰《悲愤诗》"] +["明翠摇蝉翼,纤珪理宿妆。","牛峤《女冠子·星冠霞帔》"] +["津亭秋月夜,谁见泣离群?","王勃《江亭夜月送别二首》"] +["毛骨岂殊众?驯良犹至今。","杜甫《病马》"] +["纸上得来终觉浅,绝知此事要躬行。","陆游《冬夜读书示子聿》"] +["高楼目尽欲黄昏,梧桐叶上萧萧雨。","晏殊《踏莎行·碧海无波》"] +["金灶生烟埃,玉潭秘清谧。","李白《望黄鹤楼》"] +["欻吸领地灵,鸿洞半炎方。","杜甫《望岳三首·其三》"] +["华筵鼓吹无桐竹,长刀直立割鸣筝。","李贺《公莫舞歌》"] +["晴风初破冻,柳眼梅腮,已觉春心动。","李清照《蝶恋花·暖雨晴风初破冻》"] +["煮豆燃豆萁,豆在釜中泣。","曹植《七步诗》"] +["和醉重寻幽梦,残衾已断熏香。","吴文英《风入松·桂》"] +["五音六律,依韦飨昭,杂变并会,雅声远姚。","佚名《景星》"] +["金鹅屏风蜀山梦,鸾裾凤带行烟重。","李贺《洛姝真珠》"] +["原浊者,流不清;行不信者,名必耗。","《墨子·02章 修身》"] +["稍见初日开,三两列舴艋。","厉鹗《晓至湖上》"] +["飞絮濛濛。垂柳阑干尽日风。","欧阳修《采桑子·群芳过后西湖好》"] +["传有沙场千万骑,昨日边庭羽书至。","高适《送浑将军出塞》"] +["红杏香中箫鼓,绿杨影里秋千。","俞国宝《风入松·一春长费买花钱》"] +["不堪吏人妇,岂合令郎君?","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["既见君子,云胡不夷。","佚名《风雨》"] +["居人行转轼,客子暂维舟。","何逊《与胡兴安夜别》"] +["秋风吹地百草干,华容碧影生晚寒。","李贺《开愁歌》"] +["尚想横汾,兰菊纷相半。","苏轼《点绛唇·庚午重九再用前韵》"] +["一日不读书,胸臆无佳想。","萧抡谓《读书有所见作》"] +["一鞭南陌,几篙官渡,赖有歌眉舒绿。","史达祖《八归·秋江带雨》"] +["把春波,都酿作,一江春酎。","辛弃疾《粉蝶儿·和赵晋臣敷文赋落花》"] +["筑室兮水中,葺之兮荷盖;","屈原《九歌·湘夫人》"] +["谬接瑶华枝,结根君王池。","李白《赠友人三首》"] +["宿燕夜归银烛外,啼莺声在绿阴中。","吴文英《望江南·三月暮》"] +["当时轻别意中人,山长水远知何处。","晏殊《踏莎行·碧海无波》"] +["彼人之心,于何其臻。","佚名《菀柳》"] +["雨歇梧桐泪乍收,遣怀翻自忆从头。","纳兰性德《浣溪沙·雨歇梧桐泪乍收》"] +["应是北堂增寿考,一阳旋复占先梅。","高鹗《赏海棠花妖诗》"] +["听孤雁、声嘹唳。","佚名《御街行·霜风渐紧寒侵被》"] +["秋容浅淡映重门,七节攒成雪满盆。","曹雪芹《咏白海棠》"] +["今夜月明江上、酒初醒。","黄庭坚《南歌子·槐绿低窗暗》"] +["属雷师之阗阗兮,通飞廉之衙衙。","宋玉《九辩》"] +["槲叶落山路,枳花明驿墙。","温庭筠《商山早行》"] +["葛之覃兮,施于中谷,维叶萋萋。","佚名《葛覃》"] +["水满有时观下鹭,草深无处不鸣蛙。","陆游《幽居初夏》"] +["上邪,我欲与君相知,长命无绝衰。","佚名《上邪》"] +["六曲屏山和梦遥。","纳兰性德《采桑子·九日》"] +["哲匠感颓运,云鹏忽飞翻。","李白《登金陵冶城西北谢安墩》"] +["截彼淮浦,王师之所。","佚名《大雅·常武》"] +["飞来双白鹄,乃从西北来。","佚名《艳歌何尝行》"] +["似当时、将军部曲,迤逦度沙漠。","姜夔《凄凉犯·绿杨巷陌秋风起》"] +["却恨悲风时起,冉冉云间新雁,边马怨胡笳。","叶梦得《水调歌头·秋色渐将晚》"] +["不近人情,举足尽是危机;不体物情,一生俱成梦境。","《格言联璧·接物类》"] +["人虽有南北,佛性本无南北","《六祖坛经·行由·第三节》"] +["常随宝镫陪金勒,绝塞指挥万马嘶。","俞益谟《中洲株柳》"] +["但一抹、寒青有无中,遥山色。","赵鼎《满江红·丁未九月南渡泊舟仪真江口作》"] +["一为钓叟一耕佣。","王安石《浪淘沙令·伊吕两衰翁》"] +["竹树夹流水,孤城对远山。","高适《自淇涉黄河途中作十三首》"] +["孤云还空山, 众鸟各已归。","李白《春日独酌二首》"] +["仙人如爱我,举手来相招。","李白《焦山望寥山》"] +["昔孟母,择邻处。子不学,断机杼。","《三字经·全文》"] +["笑劳生一梦,羁旅三年,又还重九。","苏轼《醉蓬莱·重九上君猷》"] +["出塞应无策,还家赖有期。","高适《使青夷军入居庸三首》"] +["思填东海,强衔一木。","李白《来日大难》"] +["鹤瘦松青,精神与、秋月争明。","李清照《新荷叶·薄露初零》"] +["雪衣雪发青玉觜,群捕鱼儿溪影中。","杜牧《鹭鸶》"] +["特上不以为政,而士不以为行故也。","《墨子·15章 兼爱(中)》"] +["躞蹀御沟上,沟水东西流。","卓文君《白头吟》"] +["若待上林花似锦,出门俱是看花人。","杨巨源《城东早春》"] +["鸟飞千白点,日没半红轮。","白居易《彭蠡湖晚归》"] +["杜陵贤人清且廉,东谿卜筑岁将淹。","李白《题东谿公幽居》"] +["衡门之下,可以栖迟。","佚名《衡门》"] +["可惜一溪风月,莫教踏碎琼瑶。","苏轼《西江月·顷在黄州》"] +["空眉皱,看白发尊前,已似人人有。","刘辰翁《摸鱼儿·酒边留同年徐云屋》"] +["陶公战舰空滩雨,贾傅承尘破庙风。","李商隐《潭州》"] +["雨细风微,两足如霜挽纻衣。","苏轼《减字木兰花·江南游女》"] +["更遣黄龙戍,唯当哭塞云。","王昌龄《塞下曲四首》"] +["有约不来梁上燕,十二绣帘空卷。","陈允平《清平乐·凤城春浅》"] +["恣游不怕,素袜尘生,行裙红溅。","吴文英《烛影摇红·元夕雨》"] +["欹枕数秋天,蟾蜍下早弦。","纳兰性德《菩萨蛮·萧萧几叶风兼雨》"] +["下莞上簟,乃安斯寝。","佚名《斯干》"] +["苜蓿峰边逢立春,胡芦河上泪沾巾。","岑参《题苜蓿峰寄家人》"] +["何用草书夸神速,开卷惝恍令人愁。","苏轼《石苍舒醉墨堂》"] +["料得年年肠断处,明月夜,短松冈。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["相知在急难,独好亦何益。","李白《君马黄》"] +["松梢露点沾鹰绁,芦叶溪深没马鞍。","纳兰性德《于中好·谁道阴山行路难》"] +["翳翳陌上桑,南枝交北堂。","常建《春词二首》"] +["沙河塘上春寒浅,看了游人缓缓归。","姜夔《鹧鸪天·正月十一日观灯》"] +["百感中来不自由,角声孤起夕阳楼。","杜牧《登池州九峰楼寄张祜》"] +["黄金白璧买歌笑,一醉累月轻王侯。","李白《忆旧游寄谯郡元参军》"] +["洒落君臣契,飞腾战伐名。","杜甫《公安县怀古》"] +["金鞭断折九马死,骨肉不得同驰驱。","杜甫《哀王孙》"] +["不枉东风吹客泪。相思难表,梦魂无据,惟有归来是。","欧阳修《青玉案·一年春事都来几》"] +["把酒送君天上去,琼玉琚玉佩軝鸿列。","严羽《满江红·送廖叔仁赴阙》"] +["故人重分携,临流驻归驾。","元好问《颍亭留别》"] +["昨夜个人曾有约,严城玉漏三更。","纳兰性德《临江仙·昨夜个人曾有约》"] +["安得夫差水犀手,三千强弩射潮低。","苏轼《八月十五日看潮五绝》"] +["忽疑君到,漆灯风飐,痴数春星。","纳兰性德《青衫湿·悼亡》"] +["怀君属秋夜,散步咏凉天。","韦应物《秋夜寄邱员外 / 秋夜寄丘二十二员外》"] +["人言落日是天涯,望极天涯不见家。","李觏《乡思》"] +["人有所优,固有所劣;人有所工,固有所拙。","《论衡·卷二十八·书解篇》"] +["娇软不胜垂,瘦怯那禁舞。","纳兰性德《卜算子·新柳》"] +["倏若异鹏抟,吾当学蝉蜕。","高适《赠别王十七管记》"] +["云日相晖映,空水共澄鲜。","谢灵运《登江中孤屿》"] +["谁生厉阶,至今为梗?","佚名《桑柔》"] +["凌晨试遣霜根送,畚玉虽微甚珍重。","胡铨《送菊》"] +["泣对铜钩障,愁看玉镜台。","杨炯《梅花落》"] +["君今上陇去,我在与谁居?","韩愈《青青水中蒲二首》"] +["有谁知我此时情,枕前泪共阶前雨,隔个窗儿滴到明。","聂胜琼《鹧鸪天·别情》"] +["巧媳妇煮不得没米粥","《警世通言·卷十二》"] +["但闻风声野鸟吟,忆平生盛年时。","鲍照《拟行路难十八首》"] +["方城次北门,溟海穷南服。","李百药《郢城怀古》"] +["学为尧舜文,时人责衰偶。","李贺《赠陈商》"] +["绕遍回廊还独坐。月笼云暗重门锁。","苏轼《蝶恋花·雨霰疏疏经泼火》"] +["非关癖爱轻模样,冷处偏佳。","纳兰性德《采桑子·塞上咏雪花》"] +["十日春寒不出门,不知江柳已摇村。","苏轼《正月二十日往岐亭郡人潘古郭三人送余于女王城东禅庄院》"] +["渭北春天树,江东日暮云。","杜甫《春日忆李白》"] +["占断花中声誉,香与韵、两清洁。","谢懋《霜天晓角·桂花》"] +["东门酤酒饮我曹,心轻万事如鸿毛。","李颀《送陈章甫》"] +["楼下分流水声中,有当日凭高泪。","晏几道《留春令》"] +["欲命巾车归去,恐豺狼当辙。","胡铨《好事近·富贵本无心》"] +["别也应难见也难,后会难凭据。","石孝友《卜算子·见也如何暮》"] +["洊雷,震;君子以恐惧修省。","《易传·象传下·震》"] +["曲终过尽松陵路,回首烟波十四桥。","姜夔《过垂虹》"] +["维其不遇,化止一国。","韩愈《子产不毁乡校颂》"] +["雌雄空中鸣,声尽呼不归。","白居易《燕诗示刘叟》"] +["亲戚或余悲,他人亦已歌。","陶渊明《拟挽歌辞三首》"] +["朝来嗔寂寞,不肯试新衣。","《喻世明言·卷一》"] +["圣达有去就,潜光愚其德。","李白《纪南陵题五松山》"] +["胡燕别主人,双双语前檐。","李白《秋浦感主人归燕寄内》"] +["不似海棠阴下,按《凉州》时节。","魏夫人《好事近·雨后晓寒轻》"] +["练于骨者,析辞必精;深乎风者,述情必显。","《文心雕龙·风骨》"] +["左右六翮利如刀,踊身失势不得高。","柳宗元《跂乌词》"] +["此生只是偿诗债,白菊开时最不眠。","司空图《白菊杂书四首》"] +["龙虎方交争,七国并抗衡。","张华《游侠篇》"] +["疲马山中愁日晚,孤舟江上畏春寒。","张谓《杜侍御送贡物戏赠》"] +["别后登此台,愿言长相思。","李白《九日登山》"] +["试看一一龙蛇活,更听萧萧风雨哀。","苏轼《送贾讷倅眉》"] +["九疑浚倾奔,临源委萦回。","柳宗元《湘口馆潇湘二水所会》"] +["孟子曰:「吾善养吾浩然之气。","文天祥《正气歌》"] +["织得绢,二丈五。","佚名《绵州巴歌》"] +["潭清疑水浅,荷动知鱼散。","储光羲《钓鱼湾》"] +["采采芣苢,薄言有之。","佚名《芣苢》"] +["我所居兮,青埂之峰。我所游兮,鸿蒙太空。谁与我游?兮,吾谁与从。","《红楼梦·第一百二十回》"] +["阁道步行月,美人愁烟空。","李白《上之回》"] +["乡远去不得,无日不瞻望。","白居易《夜雨》"] +["一日万机,一人听断,虽复忧劳,安能尽善?","《贞观政要·卷二·论求谏》"] +["天气困人梳洗懒,眉尖,淡画春山不喜添。","孙道绚《南乡子·春闺》"] +["公尸燕饮,福禄来成。","佚名《凫鹥》"] +["十年生死两茫茫,不思量,自难忘。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["纷纷世事无穷尽,天数茫茫不可逃。","《三国演义·第一百二十回》"] +["荆卿一去后,壮士多摧残。","李白《赠友人三首》"] +["谁侜予美?心焉忉忉。","佚名《防有鹊巢》"] +["蓬莱清浅对觚棱。","毛滂《临江仙·都城元夕》"] +["黄金白璧买歌笑,一醉累月轻王侯。","李白《忆旧游寄谯郡元参军》"] +["袖中赵匕首,买自徐夫人。","李白《赠友人三首》"] +["画船撑入花深处,香泛金卮。","欧阳修《采桑子·荷花开后西湖好》"] +["仍怜故乡水,万里送行舟。","李白《渡荆门送别》"] +["慢腾腾、手双垂。","蒋捷《燕归梁·凤莲》"] +["眼花耳热后,意气素霓生。","李白《侠客行》"] +["寒松萧瑟如有声,阳台微茫如有情。","李白《观元丹丘坐巫山屏风》"] +["机中织锦秦川女,碧纱如烟隔窗语。","李白《乌夜啼》"] +["不我与,其后也处。","佚名《江有汜》"] +["茶鼎熟,酒卮扬,醉来诗兴狂。","张大烈《阮郎归·立夏》"] +["又说是、蛟馋龙怒。","刘克庄《贺新郎·端午》"] +["最难忘,屏边瞥见,野外相逢。","佚名《玉蝴蝶·为甚夜来添病》"] +["九派春潮满,孤帆暮雨低。","郑锡《送客之江西》"] +["习习谷风,维山崔嵬。","佚名《小雅·谷风》"] +["冷冷水向桥东去。漠漠云归溪上住。","刘镇《玉楼春·东山探梅》"] +["黥彭甘受他年醢,饮剑何如楚帐中?","曹雪芹《五美吟·虞姬》"] +["水精帘里颇黎枕,暖香惹梦鸳鸯锦。","温庭筠《菩萨蛮·水精帘里颇黎枕》"] +["如今风雨西楼夜,不听清歌也泪垂。","周紫芝《鹧鸪天·一点残红欲尽时》"] +["钓鱼三十年,中心无所向。","高适《自淇涉黄河途中作十三首》"] +["缟衣茹藘,聊可与娱。","佚名《出其东门》"] +["羲和!羲和!汝奚汩没于荒淫之波?","李白《日出行 / 日出入行》"] +["眼想心思梦里惊,无人知我此时情。","无名氏《杂诗》"] +["使人有乍交之欢,不若使人无久处之厌。","《小窗幽记·集醒篇》"] +["晴雪小园春未到,池边梅自早。","冯延巳《醉花间·晴雪小园春未到》"] +["畚锸载埋瘗,沟渎护其危。","柳宗元《掩役夫张进骸》"] +["残萤栖玉露,早雁拂金河。","许浑《早秋三首·其一》"] +["随时变通,不可执一矣。","《反经·时宜》"] +["时缤纷其变易兮,又何可以淹留?","屈原《离骚》"] +["日暮,青盖亭亭,情人不见,争忍凌波去?","姜夔《念奴娇·闹红一舸》"] +["自从迁播落黔巴,三见江上开新花。","卢僎《十月梅花书赠》"] +["元嘉草草,封狼居胥,赢得仓皇北顾。","辛弃疾《永遇乐·京口北固亭怀古》"] +["天地偶然留砥柱,江山有此障狂澜。","谢枋得《小孤山》"] +["规小节者不能成荣名,恶小耻者不能立大功。","《史记·七十列传·鲁仲连邹阳列传》"] +["蓺之荏菽,荏菽旆旆。","佚名《大雅·生民》"] +["乡心正无限,一雁度南楼。","赵嘏《寒塘》"] +["盗贼劫宝玉,精灵竟何能?","李白《登高丘而望远》"] +["十步两躩跃,三呼一交兵。","李白《东海有勇妇》"] +["野旷吕蒙营,江深刘备城。","杜甫《公安县怀古》"] +["欲为圣明除弊事,肯将衰朽惜残年。","韩愈《左迁至蓝关示侄孙湘》"] +["身没名亦尽,念之五情热。","陶渊明《形影神三首》"] +["祸福回还车轮毂,荣枯反覆手藏钩。","白居易《放言五首·其二》"] +["来岁今朝,为我西顾,酹羽觞江口。","苏轼《醉蓬莱·重九上君猷》"] +["自古人皆望,年来又一年。","栖白《八月十五夜玩月》"] +["大驾还长安,两日忽再中。","李白《流夜郎半道承恩放还兼欣克复之美书怀示息秀才》"] +["翠叶吹凉,玉容消酒,更洒菇蒲雨。","姜夔《念奴娇·闹红一舸》"] +["座中醉客延醒客,江上晴云杂雨云。","李商隐《杜工部蜀中离席》"] +["昨宵结得梦夤缘,水云间,悄无言。","朱淑真《江城子·赏春》"] +["蜀道寒云,渭水秋风。","查德卿《蟾宫曲·怀古》"] +["邻翁能楚言,倚锸欲潸然。","温庭筠《烧歌》"] +["垂钓绿湾春,春深杏花乱。","储光羲《钓鱼湾》"] +["西家思妇见悲惋,零泪沾衣抚心叹。","鲍照《拟行路难十八首》"] +["乐只君子,万寿无疆。","佚名《南山有台》"] +["饰带以纹绣,装匣以琼瑛。","刘禹锡《昏镜词》"] +["无端袅娜临官路,舞送行人过一生。","牛峤《柳枝·解冻风来末上青》"] +["山无陵,江水为竭。","佚名《上邪》"] +["雨过横塘水满堤,乱山高下路东西。","曾巩《城南·雨过横塘水满堤》"] +["寒冬十二月,苍鹰八九毛。","李白《观放白鹰二首》"] +["舍西柔桑叶可拈,江畔细麦复纤纤。","杜甫《绝句漫兴九首》"] +["有椒其馨,胡考之宁。","佚名《周颂·载芟》"] +["日惨惨兮云冥冥,猩猩啼烟兮鬼啸雨。","李白《远别离》"] +["烟波澹荡摇空碧,楼殿参差倚夕阳。","白居易《西湖晚归回望孤山寺赠诸客》"] +["魂断,魂断,后夜松江月满。","苏轼《如梦令·题淮山楼》"] +["水月通禅寂,鱼龙听梵声。","钱起《送僧归日本》"] +["剑头唯一吷,焦谷无新颖。","苏轼《送参寥师》"] +["永夜闲阶卧桂影。","晁补之《洞仙歌·泗州中秋作》"] +["采幽香,巡古苑,竹冷翠微路。","吴文英《祝英台近·春日客龟溪游废园》"] +["踌蹰向苍天,何时更得甦?","王冕《冀州道中》"] +["淳薄既异源,旋复还幽蔽。","陶渊明《桃花源诗》"] +["剩得几行青史,斜阳下、断碣残碑。","纳兰性德《满庭芳·堠雪翻鸦》"] +["一点微酸已著枝。","苏轼《南乡子·梅花词和杨元素》"] +["土房桑树根,仿佛似酒垆。","王冕《冀州道中》"] +["吾衰久矣夫,何其不梦周?","刘琨《重赠卢谌》"] +["意倦须还,身闲贵早,岂为莼羹鲈脍哉。","辛弃疾《沁园春·带湖新居将成》"] +["天下英雄谁敌手?曹刘。生子当如孙仲谋。","辛弃疾《南乡子·登京口北固亭有怀》"] +["早秋惊落叶,飘零似客心。","孔绍安《落叶》"] +["兹游淡薄欢有余,到家恍如梦蘧蘧。","苏轼《腊日游孤山访惠勤惠思二僧》"] +["我酒既旨,我肴既臧。","陆机《短歌行》"] +["旋见衣冠就东市,忽遗弓剑不西巡。","杜牧《河湟》"] +["悲愁天地白日昏,路旁过者无颜色。","王安石《河北民》"] +["最是多情汴堤柳,春来依旧带栖鸦!","曾棨《维扬怀古》"] +["一旦归为臣虏,沈腰潘鬓消磨。","李煜《破阵子·四十年来家国》"] +["六曲屏山和梦遥。","纳兰性德《采桑子·九日》"] +["问春归、不肯带愁归,肠千结。","辛弃疾《满江红·点火樱桃》"] +["向河梁、回头万里,故人长绝。","辛弃疾《贺新郎·别茂嘉十二弟》"] +["常时相对两三峰,走遍溪头无觅处。","辛弃疾《玉楼春·戏赋云山》"] +["泛画鹢、翩翩过南浦。","柳永《夜半乐·冻云黯淡天气》"] +["闻说轮台路,连年见雪飞。","岑参《发临洮将赴北庭留别》"] +["蹉跎不得意,驱马还贵乡。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["吾今细观竞渡儿,何殊当路权相持。","张建封《竞渡歌》"] +["安陵谁辨削砺功,韩国讵明深井里。","柳宗元《古东门行》"] +["长钗坠发双蜻蜓,碧尽山斜开画屏。","温庭筠《夜宴谣》"] +["衣被皆重地,难与沉水碧。","左思《娇女诗》"] +["纤纤素手如霜雪,笑把秋花插。","苏轼《劝金船·无情流水多情客》"] +["有恨难传,无肠可断,立马多时。","赵长卿《柳梢青·过何郎石见早梅》"] +["人间此境知难必,快意翻从偶然得。","查慎行《中秋夜洞庭湖对月歌》"] +["楚人悲屈原,千载意未歇。","苏轼《屈原塔》"] +["瞻前而顾后兮,相观民之计极。","屈原《离骚》"] +["最苦是、立尽月黄昏,阑干曲。","辛弃疾《满江红·敲碎离愁》"] +["不斩奸邪恨最深!","宇文虚中《在金日作·选二》"] +["重闻处,余熏在,这一番、气味胜从前。","苏轼《翻香令·金炉犹暖麝煤残》"] +["老人七十仍沽酒,千壶百瓮花门口。","岑参《戏问花门酒家翁》"] +["抗声促遗诏,定命由陈辞。","柳宗元《哭连州凌员外司马》"] +["君情与妾意,各自东西流。","李白《妾薄命》"] +["扬人恶 即是恶 疾之甚 祸且作","《弟子规·泛爱众》"] +["虽抱文章,开口谁亲。","苏轼《行香子·述怀》"] +["湔裙水满绿苹洲,上巳微寒懒出游。","范成大《晚春田园杂兴》"] +["粉河不语堕秋晓。","吴文英《秋蕊香·七夕》"] +["乐只君子,殿天子之邦。","佚名《采菽》"] +["背飞双燕贴云寒,独向小楼东畔、倚阑看。","舒亶《虞美人·寄公度》"] +["纵豆蔻词工,青楼梦好,难赋深情。","姜夔《扬州慢·淮左名都》"] +["乘我大宛马,抚我繁弱弓。","张华《壮士篇》"] +["凉秋八月萧关道,北风吹断天山草。","岑参《胡笳歌送颜真卿使赴河陇》"] +["枝横却月观,花绕凌风台。","何逊《咏早梅 / 扬州法曹梅花盛开》"] +["书罢笼鹅去,何曾别主人。","李白《王右军》"] +["横笛和愁听,斜枝倚病看。","崔道融《梅花》"] +["王事适我,政事一埤益我。","佚名《北门》"] +["风流紫府郎,痛饮乌纱岸。","金章宗《生查子·软金杯》"] +["上有所欲,其神若亲,礼下勿辞,拒者无助矣。","《罗织经·治下卷第三》"] +["春去春来洞庭阔,白苹愁杀白头翁。","杜甫《清明二首》"] +["那堪更被明月,隔墙送过秋千影。","张先《青门引·春思》"] +["万一不合意,永为世笑嗤。","陶渊明《拟古九首》"] +["痴心做处人人爱,冷眼观时个个嫌。","《喻世明言·卷三》"] +["苍旻霁凉雨,石路无飞尘。","岑参《暮秋山行》"] +["爱之不以道,适所以害之也。","《资治通鉴·晋纪十八》"] +["酒能祛百虑,菊解制颓龄。","陶渊明《九日闲居》"] +["不知乘月几人归,落月摇情满江树。","张若虚《春江花月夜》"] +["人生能如此,何必归故家。","李清照《晓梦》"] +["相见初经田窦家,侯门歌舞出如花。","吴伟业《圆圆曲》"] +["行得春风,指望夏雨","《警世通言·卷二十五》"] +["石桥锁,烟霞五百名仙,第一人是。","吴文英《金盏子·赋秋壑西湖小筑》"] +["惨郁郁而不通兮,蹇侘傺而含戚。","屈原《哀郢》"] +["老母与子别,呼天野草间。","李白《豫章行》"] +["韎韐有奭,以作六师。","佚名《瞻彼洛矣》"] +["奔流疑激电,惊浪似浮霜。","韩愈《宿龙宫滩》"] +["明媚鲜妍能几时?一朝漂泊难寻觅。","曹雪芹《葬花吟》"] +["今年海角天涯。萧萧两鬓生华。","李清照《清平乐·年年雪里》"] +["唯柳色夹道,依依可怜。","姜夔《淡黄柳·空城晓角》"] +["槲叶落山路,枳花明驿墙。","温庭筠《商山早行》"] +["夕照临轩堕,栖鸟当我还。","柳宗元《构法华寺西亭》"] +["爰采麦矣?沬之北矣。","佚名《桑中》"] +["清明时节雨纷纷,路上行人欲断魂。","杜牧《清明》"] +["万籁此俱寂,但余钟磬音。","常建《题破山寺后禅院》"] +["天涯芳草迷归路,病叶还禁一夜霜。","王夫之《初度口占·辛丑》"] +["中庭寒尽雪微销。","吴文英《浣溪沙·琴川慧日寺蜡梅》"] +["此时樯底水,涛起屈原通。","贾岛《寄朱锡珪》"] +["聊对旧节传杯,尘笺蠹管,断阕经岁慵赋。","吴文英《霜叶飞·重九》"] +["入市虽求利,怜君意独真。","于武陵《赠卖松人》"] +["皎皎白驹,在彼空谷。","佚名《白驹》"] +["黄山四千仞,三十二莲峰。","李白《送温处士归黄山白鹅峰旧居》"] +["北山白云里,隐者自怡悦。","孟浩然《秋登兰山寄张五》"] +["相去万余里,各在天一涯。","佚名《行行重行行》"] +["梦到凤凰台上,山围故国周遭。","完颜璹《朝中措·襄阳古道灞陵桥》"] +["缛彩遥分地,繁光远缀天。","卢照邻《十五夜观灯》"] +["若使学萧曹,功名当不朽。","高适《自淇涉黄河途中作十三首》"] +["太守未来,两县令先在。","苏轼《瑞鹧鸪·城头月落尚啼乌》"] +["引疏砧、断续残阳里。","柳永《卜算子慢·江枫渐老》"] +["我行其野,蔽芾其樗。","佚名《我行其野》"] +["凝神闭气昔登攀,恬然但觉心绪闲。","李白《下途归石门旧居》"] +["时俗薄朱颜,谁为发皓齿。","曹植《杂诗七首》"] +["自知者不怨人,知命者不怨天;怨人者穷,怨天者无志。","《荀子·荣辱》"] +["身是客、愁为乡。","史达祖《寿楼春·寻春服感念》"] +["吾令丰隆乘云兮,求宓妃之所在。","屈原《离骚》"] +["且由他、娥眉谣诼,古今同忌。","纳兰性德《金缕曲·赠梁汾》"] +["鸾弦代雁曲如语,一醉昏昏天下迷。","温庭筠《春江花月夜词》"] +["败叶填溪水已冰,夕阳犹照短长亭。","纳兰性德《浣溪沙·败叶填溪水已冰》"] +["路断车轮生四角,此地行人销骨。","辛弃疾《贺新郎·把酒长亭说》"] +["西窗下,风摇翠竹,疑是故人来。","秦观《满庭芳·碧水惊秋》"] +["杨花榆荚无才思,惟解漫天作雪飞。","韩愈《晚春》"] +["搴帷拜母河梁去,白发愁看泪眼枯。","黄景仁《别老母》"] +["制在己曰重,不离位曰静。重则能使轻,静则能使躁。","《韩非子·喻老》"] +["彼其之子,硕大且笃。","佚名《椒聊》"] +["门前记取垂杨树,只藏他、三两秋莺。","郭麐《高阳台·将反魏塘疏香女子亦以次日归吴下置酒话别离怀惘惘》"] +["人言落日是天涯,望极天涯不见家。","李觏《乡思》"] +["千回来绕百回看,蜂作婢,莺为使。","苏轼《天仙子·走马探花花发未》"] +["岭猿同旦暮,江柳共风烟。","刘长卿《新年作》"] +["思皇多士,生此王国。","佚名《文王》"] +["柳丝长,春雨细,花外漏声迢递。","温庭筠《更漏子·柳丝长》"] +["翠涟皱净,笙歌未散。","吴文英《瑞龙吟·德清清明竞渡》"] +["四国有王,郇伯劳之。","佚名《下泉》"] +["室中更无人,惟有乳下孙。","杜甫《石壕吏》"] +["最是秋风管闲事,红他枫叶白人头。","赵翼《野步》"] +["妾身未分明,何以拜姑嫜?","杜甫《新婚别》"] +["箫鼓聒川岳,沧溟涌涛波。","李白《发白马》"] +["军门号令严,震肃将天威。","钱嶫《悯黎咏》"] +["孤城暮角,引胡笳怨。","柳永《迷神引·一叶扁舟轻帆卷》"] +["安知南山桂,绿叶垂芳根。","李白《咏桂》"] +["有娀方将,帝立子生商。","佚名《商颂·长发》"] +["最难忘,屏边瞥见,野外相逢。","佚名《玉蝴蝶·为甚夜来添病》"] +["春风得意马蹄疾,一日看尽长安花。","孟郊《登科后》"] +["凡大事皆起于小事,小事不论,大事又将不可救","《贞观政要·卷一·论政体》"] +["伤心南浦波,回首青门道。","贺铸《绿罗裙·东风柳陌长》"] +["举杯邀明月,对影成三人。","李白《月下独酌四首·其一》"] +["梅雪争春未肯降,骚人搁笔费评章。","卢梅坡《雪梅·其一》"] +["外湖莲子长参差,霁山青处鸥飞。","张先《画堂春·外湖莲子长参差》"] +["居人匹马映林嘶,行人去棹依波转。","晏殊《踏莎行·祖席离歌》"] +["金陵路、莺吟燕舞。","姜夔《杏花天影·绿丝低拂鸳鸯浦》"] +["橐驼何连连,穹帐亦累累。","岑参《北庭西郊候封大夫受降回军献上》"] +["那堪疏雨滴黄昏。更特地、忆王孙。","欧阳修《少年游·栏干十二独凭春》"] +["松下柴门闭绿苔,只有蝴蝶双飞来。","饶节《偶成》"] +["晚来翠眉宫样,巧把远山学。","晏几道《六幺令·绿阴春尽》"] +["强饭日逾瘦,狭衣秋已寒。","元好问《倪庄中秋》"] +["昨者州前捶大鼓,嗣皇继圣登夔皋。","韩愈《八月十五夜赠张功曹》"] +["不忍覆余觞,临风泪数行。","纳兰性德《菩萨蛮·催花未歇花奴鼓》"] +["逸气假毫翰,清风在竹林。","孟浩然《洗然弟竹亭》"] +["好事不出门,坏事传千里。","《增广贤文·上集》"] +["尔酒既旨,尔肴既嘉。","佚名《頍弁》"] +["且复穹庐拜,会向藁街逢!","陈亮《水调歌头·送章德茂大卿使虏》"] +["尔不我畜,复我邦家。","佚名《我行其野》"] +["不因俊俏难为友,正为风流始读书。","《红楼梦·第七回》"] +["药炉初沸短檠青,无那残香半缕恼多情。","纳兰性德《虞美人·黄昏又听城头角》"] +["忽大风起,尘气莽莽然,城市依稀而已。","蒲松龄《山市》"] +["伤高怀远几时穷?无物似情浓。","张先《一丛花令·伤高怀远几时穷》"] +["楼观岳阳尽,川迥洞庭开。","李白《与夏十二登岳阳楼》"] +["伤心秦汉,生民涂炭,读书人一声长叹。","张可久《卖花声·怀古》"] +["父子之严,不可以狎;骨肉之爱,不可以简。","《颜氏家训·教子篇》"] +["小院新凉,晚来顿觉罗衫薄。","纳兰性德《点绛唇·小院新凉》"] +["画角悲海月,征衣卷天霜。","李白《出自蓟北门行》"] +["富而可求也,虽执鞭之士,吾亦为之,如不可求,从吾所好。","《论语·述而篇》"] +["慨当初,倚飞何重,后来何酷。","文征明《满江红·拂拭残碑》"] +["始期忧患弭,卒动灾祸枢。","柳宗元《咏荆轲》"] +["不知庭霰今朝落,疑是林花昨夜开。","宋之问《苑中遇雪应制》"] +["支离东北风尘际,漂泊西南天地间。","杜甫《咏怀古迹五首·其一》"] +["相顾无相识,长歌怀采薇。","王绩《野望》"] +["朝饮木兰之坠露兮,夕餐秋菊之落英。","屈原《离骚》"] +["乘秋逢令节,锡宴观群情。","李泌《奉和圣制重阳赐会聊示所怀》"] +["知子之好之,杂佩以报之。","佚名《女曰鸡鸣》"] +["安知南山桂,绿叶垂芳根。","李白《咏桂》"] +["新绿园林晓气凉,晨炊蚤出看移秧。","范成大《晚春田园杂兴》"] +["天也妒,未信与,莺儿燕子俱黄土。","元好问《摸鱼儿·雁丘词 / 迈陂塘》"] +["谁其尸之?有齐季女。","佚名《采蘋》"] +["严城时未启,前路拥笙歌。","王维《从岐王过杨氏别业应教》"] +["却想华年故国时,唯馀一片空心在。","卢僎《十月梅花书赠》"] +["周从后稷至文武,积德累功世勤苦。","白居易《八骏图》"] +["卒遇回风起,吹我入云间。","曹植《吁嗟篇》"] +["将骋万里涂,东路安足由。","曹植《杂诗七首》"] +["夕阳西下,塞雁南飞,渭水东流。","康与之《诉衷情令·长安怀古》"] +["乍见翻疑梦,相悲各问年。","司空曙《云阳馆与韩绅宿别》"] +["皎皎窗中月,照我室南端。","潘安《悼亡诗三首》"] +["晓来一阵,晚来一阵,难道都吹落?","顾德辉《青玉案·春寒恻恻春阴薄》"] +["但远山长,云山乱,晓山青。","苏轼《行香子·过七里濑》"] +["旧曲梅花唱,新正柏酒传。","孟浩然《岁除夜会乐城张少府宅》"] +["案上不可多书,心中不可少书。","《格言联璧·学问类》"] +["惊沙猎猎风成阵,白雁一声霜有信。","元好问《玉楼春·惊沙猎猎风成阵》"] +["晚春盘马踏青苔,曾傍绿荫深驻。","晏几道《御街行·街南绿树春饶絮》"] +["迥临飞鸟上,高出世尘间。","畅当《登鹳雀楼》"] +["天位殷适,使不挟四方。","佚名《大明》"] +["见贤思齐焉,见不贤而内自省也。","《论语·里仁篇》"] +["七星在北户,河汉声西流。","杜甫《同诸公登慈恩寺塔》"] +["芦叶有声疑雾雨,浪花无际似潇湘。","温庭筠《南湖》"] +["暗凝伫。近重阳、满城风雨。","周密《扫花游·九日怀归》"] +["都门帐饮无绪,留恋处,兰舟催发。","柳永《雨霖铃·寒蝉凄切》"] +["四弦夜语,问杨琼、往事到寒砧。","吴文英《木兰花慢·送翁五峰游江陵》"] +["新郎君、成行如画。","柳永《柳初新·东郊向晓星杓亚》"] +["乃知柳子语不妄,病嗜土炭如珍羞。","苏轼《石苍舒醉墨堂》"] +["温恭朝夕,执事有恪,顾予烝尝,汤孙之将。","佚名《商颂·那》"] +["恨入空帷鸾影独,泪凝双脸渚莲光,薄情年少悔思量。","顾敻《浣溪沙·荷芰风轻帘幕香》"] +["弱龄接光景,矫翼攀鸿鸾。","李白《秋日炼药院镊白发赠元六兄林宗》"] +["名园依绿水,野竹上青霄。","杜甫《陪郑广文游何将军山林》"] +["无端轻薄雨,滴损檀心,小叠宫罗镇长皱。","纳兰性德《洞仙歌·咏黄葵》"] +["谓从丹霄落,乃是故乡亲。","李白《门有车马客行》"] +["放船开看雪山晴,风定奇寒晚更凝。","范成大《冬日田园杂兴》"] +["我念梅花花念我,关情。","黄升《南乡子·冬夜》"] +["有扁斯石,履之卑兮。","佚名《白华》"] +["雨残稍觉江天暮。","柳永《安公子·远岸收残雨》"] +["兹辰始澄霁,纤云尽褰开。","柳宗元《湘口馆潇湘二水所会》"] +["寺里山因花得名,繁英不见草纵横。","苏舜钦《题花山寺壁》"] +["主称会面难,一举累十觞。","杜甫《赠卫八处士》"] +["新愁换尽风流性,偏恨鸳鸯不念人。","史达祖《鹧鸪天·搭柳栏干倚伫频》"] +["朱雀桥边野草花,乌衣巷口夕阳斜。","刘禹锡《乌衣巷》"] +["听杜宇声声,劝人不如归去。","柳永《安公子·远岸收残雨》"] +["咳唾落九天,随风生珠玉。","李白《妾薄命》"] +["闻范子丰新第园池,与此孰胜?","苏轼《临皋闲题》"] +["小童一棹舟如叶,独自编阑鸭阵归。","范成大《晚春田园杂兴》"] +["江帆几片疾如箭,山泉千尺飞如电。","周德清《塞鸿秋·浔阳即景》"] +["诚者,不勉而中不思而得:从容中道,圣人也。","《中庸·第二十章》"] +["沈香断续玉炉寒,伴我情怀如水。","李清照《孤雁儿·藤床纸帐朝眠起》"] +["秦女含颦向烟月,愁红带露空迢迢。","温庭筠《惜春词》"] +["东流不作西归水,落花辞条归故林。","李白《白头吟》"] +["若遇邪淫者,说雀鸽鸳鸯报。","《地藏经·阎浮众生业感》"] +["风枝惊暗鹊,露草覆寒蛩。","戴叔伦《客夜与故人偶集 / 江乡故人偶集客舍》"] +["伤情处,高城望断,灯火已黄昏。","秦观《满庭芳·山抹微云》"] +["朝来何事绿鬟倾。","苏轼《浣溪沙·春情》"] +["卸云帆、水驿鱼乡。","柳永《彩云归·蘅皋向晚舣轻航》"] +["玉可碎而不可改其白,竹可焚而不可毁其节。","《三国演义·第七十六回》"] +["君看石芒砀,掩泪悲千古。","李白《丁督护歌》"] +["徒把金戈挽落晖,南冠无奈北风吹。","虞集《挽文丞相》"] +["无思远人,劳心怛怛。","佚名《甫田》"] +["空掩袖,倚寒竹。","蒋捷《贺新郎·梦冷黄金屋》"] +["露清枕簟藕花香,恨悠扬。","顾敻《虞美人·触帘风送景阳钟》"] +["昨夜西风凋碧树,独上高楼,望尽天涯路。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["岳阳城下水漫漫,独上危楼凭曲阑。","白居易《题岳阳楼》"] +["朝搴阰之木兰兮,夕揽洲之宿莽。","屈原《离骚》"] +["翠羽南巡,叩阍无路,徒有冲冠发。","黄中辅《念奴娇·炎精中否》"] +["负太白,腾天狼。","柳宗元《唐铙歌鼓吹曲十二首·其四》"] +["惭愧流莺相厚意,清晨犹为到西园。","韩偓《春尽》"] +["群公先正,则不我闻。","佚名《云汉》"] +["盛年妖艳浮华辈,不久亦当诣冢头。","鲍照《拟行路难十八首》"] +["时俗薄朱颜,谁为发皓齿。","曹植《杂诗七首》"] +["夏之日,冬之夜。","佚名《葛生》"] +["倚楼无语欲销魂,长空黯淡连芳草。","寇准《踏莎行·春暮》"] +["是处红衰翠减,苒苒物华休。","柳永《八声甘州·对潇潇暮雨洒江天》"] +["今来一登望,如上九天游。","李白《登锦城散花楼》"] +["记玉关踏雪事清游,寒气脆貂裘。","张炎《八声甘州·记玉关踏雪事清游》"] +["忧民之忧者,民亦忧其忧。","《孟子·梁惠王章句下·第四节》"] +["三十未有二十余,白日长饥小甲蔬。","李贺《南园十三首》"] +["蹀座吹长笛,愁杀行客儿。","佚名《折杨柳歌辞五首》"] +["窗间梅熟落蒂,墙下笋成出林。","范成大《喜晴》"] +["传闻九鼎沦泗上,欲使万夫沉水取。","苏轼《石鼓歌》"] +["积疴谢生虑,寡欲罕所阙。","谢灵运《邻里相送至方山》"] +["远山眉样认心期,流水车音牵目送。","贺铸《木兰花·清琴再鼓求凰弄》"] +["耿斜河,疏星残月,断云微度。","张元干《贺新郎·送胡邦衡待制赴新州》"] +["萋萋芳草小楼西,云压雁声低。","刘基《眼儿媚·秋思》"] +["未怪平阳客,双泪落、笛中哀曲。","周邦彦《大酺·越调春雨》"] +["佩缤纷其繁饰兮,芳菲菲其弥章。","屈原《离骚》"] +["夕殿萤飞思悄然,孤灯挑尽未成眠。","白居易《长恨歌》"] +["不自贵于物而物宗焉,不自重于人而人敬焉。","《晋书·列传·第二十五章》"] +["薄悻不来门半掩,斜阳。","冯延巳《南乡子·细雨湿流光》"] +["鹿门月照开烟树,忽到庞公栖隐处。","孟浩然《夜归鹿门山歌 / 夜归鹿门歌》"] +["今日何日兮,得与王子同舟。","佚名《越人歌》"] +["岂不怀归?王事靡盬,我心伤悲。","佚名《四牡》"] +["昊天不惠,降此大戾。","佚名《节南山》"] +["西城杨柳弄春柔,动离忧,泪难收。","秦观《江城子·西城杨柳弄春柔》"] +["睡里消魂无说处,觉来惆怅消魂误。","晏几道《蝶恋花·梦入江南烟水路》"] +["愿得常巧笑,携手同车归。","佚名《凛凛岁云暮》"] +["东风官柳舞雕墙。","辛弃疾《酒泉子·无题》"] +["岂弟君子,福禄攸降。","佚名《旱麓》"] +["卿但暂还家,吾今且报府。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["也应惊问:近来多少华发?","辛弃疾《念奴娇·书东流村壁》"] +["崖州万里窜酷吏,湖南几时起卧龙?","陆游《二月二十四日作》"] +["草秀故春色,梅艳昔年妆。","李世民《元日》"] +["嗟我妇子,曰为改岁,入此室处。","佚名《七月》"] +["无冬无夏,值其鹭羽。","佚名《宛丘》"] +["紫盖独不朝,争长嶫相望。","杜甫《望岳三首》"] +["问别来、解相思否。","吴文英《惜秋华·七夕前一日送人归盐官》"] +["如何四纪为天子,不及卢家有莫愁。","李商隐《马嵬二首》"] +["已喜皇威清海岱,常思仙仗过崆峒。","杜甫《洗兵马》"] +["夜来处处试新妆。","刘辰翁《西江月·新秋写兴》"] +["不意金吾子,娉婷过我庐。","辛延年《羽林郎》"] +["但用东山谢安石,为君谈笑净胡沙。","李白《永王东巡歌十一首》"] +["亦云悲朝泣闲房,又闻暮思泪沾裳。","鲍照《拟行路难十八首》"] +["寂寂闻猿愁,行行见云收。","李白《寻高凤石门山中元丹丘》"] +["昨宵庭外悲歌发,知是花魂与鸟魂。","曹雪芹《葬花吟》"] +["无信人之言,人实不信。","佚名《郑风·扬之水》"] +["玉楼珠殿,相映月轮边。","牛希济《临江仙·洞庭波浪颭晴天》"] +["愁里高歌梁父吟,犹如金玉戛商音。","郑思肖《二砺》"] +["好学深思,心知其意。","《史记·五帝本纪》"] +["针药可屡进,愁思难为数。","秦嘉《赠妇诗三首》"] +["洛郊无俎豆,弊厩惭老马。","李贺《勉爱行二首送小季之庐山》"] +["初晴山院里,何处染嚣尘。","王勃《仲春郊外》"] +["怅玉钿似扫,朱门深闭,再见无路。","姜夔《月下笛·与客携壶》"] +["盛事惟忠,庸则自从。","《罗织经·控权卷第四》"] +["汉中开汉业,问此地、是耶非。","辛弃疾《木兰花慢·席上送张仲固帅兴元》"] +["赖多山水趣,稍解别离情。","王维《晓行巴峡》"] +["谷口春残黄鸟稀,辛夷花尽杏花飞。","钱起《晚春归山居题窗前竹 / 暮春归故山草堂诗》"] +["念秦楼也拟人归,应剪菖蒲自酌。","吴文英《澡兰香·淮安重午》"] +["地若不爱酒,地应无酒泉。","李白《月下独酌四首》"] +["白头居士无呵殿,只有乘肩小女随。","姜夔《鹧鸪天·正月十一日观灯》"] +["眼前直下三千字,胸次全无一点尘。","于谦《观书》"] +["赌近盗,淫近杀。","《警世通言·卷三十五》"] +["步余马于兰皋兮,驰椒丘且焉止息。","屈原《离骚》"] +["美人隔湘浦,一夕生秋风。","柳宗元《初秋夜坐赠吴武陵》"] +["有意送春归,无计留春住。","如晦《楚天遥过清江引·有意送春归》"] +["群轻折轴,下沉黄泉。","李白《雪谗诗赠友人》"] +["被冷香消新梦觉,不许愁人不起。","李清照《念奴娇·春情》"] +["工于论人者,察己常阔疏;狃于讦直者,发言多弊病。","《格言联璧·接物类》"] +["蝶衣晒粉花枝舞,蛛网添丝屋角晴。","张耒《夏日三首·其一》"] +["归心海外见明月,别思天边梦落花。","李昂《从军行》"] +["静言庸违,像恭滔天。","《尚书·虞书·尧典》"] +["西秦北赵各称高,池上张筵列我曹。","汪遵《渑池》"] +["多情管定烟和雨。","张鎡《菩萨蛮·芭蕉》"] +["矜伪不长,盖虚不久。","《韩非子·难一》"] +["主人有酒欢今夕,请奏鸣琴广陵客。","李颀《琴歌》"] +["因度此阕,以纾客怀。","姜夔《淡黄柳·空城晓角》"] +["遨游盛宛洛,冠盖随风还。","李白《南都行》"] +["此中有真意,欲辨已忘言。","陶渊明《饮酒·其五》"] +["鸷鹗啄孤凤,千春伤我情。","李白《望鹦鹉洲怀祢衡》"] +["霜落荆门江树空,布帆无恙挂秋风。","李白《秋下荆门》"] +["系缆渔村,月暗孤灯火。","苏轼《蝶恋花·暮春别李公择》"] +["和衣拥被不成眠,一枕万回千转。","柳永《御街行·前时小饮春庭院》"] +["宣华,故蜀苑名。","陆游《月上海棠·斜阳废苑朱门闭》"] +["惊回千里梦,已三更。起来独自绕阶行。","岳飞《小重山·昨夜寒蛩不住鸣》"] +["儿童急走追黄蝶,飞入菜花无处寻。","杨万里《宿新市徐公店》"] +["两两轻红半晕腮,依依独为使君回。","苏轼《定风波·两两轻红半晕腮》"] +["霜叶未衰吹未落。","苏轼《谒金门·秋兴》"] +["瑳兮瑳兮,其之展也。","佚名《君子偕老》"] +["千岩泉洒落,万壑树萦回。","李白《送友人寻越中山水》"] +["淋漓醉墨,看龙蛇飞落蛮笺。","陆游《汉宫春·初自南郑来成都作》"] +["赖多山水趣,稍解别离情。","王维《晓行巴峡》"] +["万里他乡,非生非死,此身良苦。","纳兰性德《水龙吟·题文姬图》"] +["峭寒催换木棉裘,倚杖郊原作近游。","赵翼《野步》"] +["当时明月,依依素影,何处飞来?","倪瓒《人月圆·伤心莫问前朝事》"] +["其安易持,其未兆易谋;其脆易泮,其微易散。为之于未有,治之于未乱。","《老子·德经·第六十四章》"] +["重见金英人未见。相思一夜天涯远。","晏几道《蝶恋花·黄菊开时伤聚散》"] +["山净江空水见沙,哀猿啼处两三家。","韩愈《答张十一功曹》"] +["谁知鼙鼓动渔阳?","杨万里《浯溪摩崖怀古》"] +["旱既大甚,蕴隆虫虫。","佚名《云汉》"] +["日用必须简省,杜奢端,即以昭俭德。","《围炉夜话·第八三则》"] +["君今上陇去,我在与谁居?","韩愈《青青水中蒲二首》"] +["白苎新袍入嫩凉。","辛弃疾《鹧鸪天·送廓之秋试》"] +["少年游侠好经过,浑身装束皆绮罗。","李白《少年行三首·其三》"] +["暮雨相呼失,寒塘欲下迟。","崔涂《孤雁二首·其二》"] +["不要人夸好颜色,只流清气满乾坤。","王冕《墨梅》"] +["停车坐爱枫林晚,霜叶红于二月花。","杜牧《山行》"] +["有为者辟若掘井,掘井九轫而不及泉,犹为弃井也。","《孟子·尽心章句上·第二十九节》"] +["傲然遂独往,长啸开岩扉。","李白《赠别王山人归布山》"] +["古今如梦,何曾梦觉,但有旧欢新怨。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["高堂虚且迥,坐卧见南山。","白居易《伤大宅》"] +["使君心在,苍_绿嶂,苦被北门留住。","姜夔《永遇乐·次稼轩北固楼词韵》"] +["碧艾香蒲处处忙。谁家儿共女,庆端阳。","舒頔《小重山·端午》"] +["绿衣监使守宫门,一闭上阳多少春。","白居易《上阳白发人》"] +["旦则号泣行,夜则悲吟坐。","蔡琰《悲愤诗》"] +["紞如三鼓,铿然一叶,黯黯梦云惊断。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["独散万古意,闲垂一溪钓。","李白《经乱后将避地剡中留赠崔宣城》"] +["铫煎黄蕊色,碗转曲尘花。","元稹《一七令·茶》"] +["记得别伊时,桃花柳万丝。","纳兰性德《菩萨蛮·新寒中酒敲窗雨》"] +["如彼游川鱼,比目中路析。","潘安《悼亡诗三首》"] +["既醉既饱,小大稽首。","佚名《楚茨》"] +["俯临秦山川,高会汉公卿。","李泌《奉和圣制重阳赐会聊示所怀》"] +["坐中有狂客,恼乱愁肠。","苏轼《满庭芳·香叆雕盘》"] +["九十方称半,归途讵有踪。","阴铿《晚出新亭》"] +["杏花红处青山缺,山畔行人山下歇。","欧阳修《玉楼春·洛阳正值芳菲节》"] +["身无彩凤双飞翼,心有灵犀一点通。","李商隐《无题·昨夜星辰昨夜风》"] +["春风来不远,只在屋东头。","于谦《除夜太原寒甚》"] +["扬州梦觉,彩云飞过何许?","姜夔《月下笛·与客携壶》"] +["旧时明月照扬州。","纳兰性德《浣溪沙·红桥怀古和王阮亭韵》"] +["花落花开,不管年华度。","司马槱《黄金缕·家在钱塘江上住》"] +["背绳墨以追曲兮,竞周容以为度。","屈原《离骚》"] +["淑人君子,其带伊丝。","佚名《鳲鸠》"] +["今春香肌瘦几分?缕带宽三寸。","王实甫《十二月过尧民歌·别情》"] +["两鬟何窈窕,一世良所无。","辛延年《羽林郎》"] +["直到城头总是花。","欧阳修《采桑子·清明上巳西湖好》"] +["父老得书知我在,小轩临水为君开。","苏轼《送贾讷倅眉》"] +["骝马新跨白玉鞍,战罢沙场月色寒。","王昌龄《出塞二首》"] +["常随宝镫陪金勒,绝塞指挥万马嘶。","俞益谟《中洲株柳》"] +["牡丹破萼樱桃熟,未许飞花减却春。","范成大《晚春田园杂兴》"] +["命之不易,无遏尔躬。","佚名《文王》"] +["君难托,妾亦不忘旧时约。","王安石《君难托》"] +["春风知别苦,不遣柳条青。","李白《劳劳亭》"] +["败不可处,时不可失,忠不可弃,怀不可从","《国语·晋语·齐姜劝重耳勿怀安》"] +["采石如壮士,铁骑明鋈续。","洪咨夔《送监丞家同年守简池三十韵》"] +["自应殊显晦,宁复待剖判。","赵蕃《次韵酬吴德夫去秋送行之作》"] +["爰有寒泉?在浚之下。","佚名《凯风》"] +["及至中秋满,还胜别夜圆。","栖白《八月十五夜玩月》"] +["瞻望弗及,伫立以泣。","佚名《燕燕》"] +["儿戏不足道,五噫出西京。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["万里乘云去复来,只身东海挟春雷。","秋瑾《黄海舟中日人索句并见日俄战争地图》"] +["碎身粉骨,功合上凌烟。","黄庭坚《满庭芳·茶》"] +["甚时跃马归来,认得迎门轻笑。","时彦《青门饮·寄宠人》"] +["塔势如涌出,孤高耸天宫。","岑参《与高适薛据同登慈恩寺浮图》"] +["交河城边鸟飞绝,轮台路上马蹄滑。","岑参《天山雪歌送萧治归京》"] +["思悠悠,垂帘独坐,传遍熏笼。","佚名《玉蝴蝶·为甚夜来添病》"] +["湖水无端浸白云,故人书断孤鸿没。","黄庭坚《秋怀二首》"] +["妾意逐君行,缠绵亦如之。","李白《自代内赠》"] +["茂陵多病后,尚爱卓文君。","杜甫《琴台》"] +["事莫貴乎有驗,言莫棄乎無徴。","《中论·上卷·貴驗》"] +["怕被郎窥,移却青鸾镜。","纳兰性德《鬓云松令·咏浴》"] +["微风惊暮坐,临牖思悠哉。","李益《竹窗闻风寄苗发司空曙》"] +["缄之箧笥久,以俟公家须。","杜甫《客从》"] +["恰似春风相欺得,夜来吹折数枝花。","杜甫《绝句漫兴九首·其二》"] +["孤云将野鹤,岂向人间住。","刘长卿《送方外上人 / 送上人》"] +["周公吐哺,天下归心。","曹操《短歌行》"] +["击筑向北燕,燕歌易水滨。","李白《鲁郡尧祠送张十四游河北》"] +["呜呼!楚虽三户能亡秦,岂有堂堂中国空无人!","陆游《金错刀行》"] +["疏篱下、试觅重阳,醉擘青露菊。","吴文英《一寸金·秋感》"] +["窗竹影摇书案上,野泉声入砚池中。","杜荀鹤《题弟侄书堂》"] +["嚣尘自兹隔,赏心于此遇。","谢朓《之宣城郡出新林浦向板桥》"] +["误落尘网中,一去三十年。","陶渊明《归园田居·其一》"] +["鞭扑之子,不从父之教","《说苑·杂言》"] +["叹一笑论文,清狂顾曲,此会几时又?","刘辰翁《摸鱼儿·酒边留同年徐云屋》"] +["玉骢惯识西湖路,骄嘶过、沽酒楼前。","俞国宝《风入松·一春长费买花钱》"] +["卧迟灯灭后,睡美雨声中。","白居易《秋雨夜眠》"] +["先君之思,以勖寡人。","佚名《燕燕》"] +["鞭弭周旋,旌旗麾动,坐却北军风靡。","李纲《喜迁莺·晋师胜淝上》"] +["远牧牛,绕村四面禾黍稠。","李涉《牧童词》"] +["丹崖夹石柱,菡萏金芙蓉。","李白《送温处士归黄山白鹅峰旧居》"] +["此地一垂顾,高名百代留。","王世贞《登太白楼》"] +["淠彼泾舟,烝徒楫之。","佚名《棫朴》"] +["使君留客醉厌厌。","苏轼《江神子·黄昏犹是雨纤纤》"] +["杂彩三百匹,交广市鲑珍。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["望断行云无觅处,梦回明月生南浦。","司马槱《黄金缕·妾本钱塘江上住》"] +["莫惜尊前仔细看,应是容颜老。","苏轼《卜算子·感旧》"] +["落红铺径水平池,弄晴小雨霏霏。","秦观《画堂春·落红铺径水平池》"] +["从今后,断魂千里,夜夜岳阳楼。","徐君宝妻《满庭芳·汉上繁华》"] +["竟日蛟龙喜,盘涡与岸回。","杜甫《梅雨》"] +["喜传语者,不可与语。好议事者,不可图事。","《小窗幽记·集醒篇》"] +["君子偕老,副笄六珈。","佚名《君子偕老》"] +["麋鹿逢人虽未惯,猿猱闻喜不须呼。","苏轼《浣溪沙·照日深红暖见鱼》"] +["此阁几何高,何人之所营。","苏轼《真兴寺阁》"] +["岸柳垂金线,雨晴莺百啭。","顾敻《醉公子·岸柳垂金线》"] +["莫遣素娥知道,和他发也苍华。","刘克庄《木兰花慢·丁未中秋》"] +["归来视幼女,零泪缘缨流。","韦应物《送杨氏女》"] +["笺麻素绢排数箱,宣州石砚墨色光。","李白《草书歌行》"] +["烟霏霏。雪霏霏。雪向梅花枝上堆。","吴淑姬《长相思令·烟霏霏》"] +["习习谷风,维山崔嵬。","佚名《小雅·谷风》"] +["怡然有余乐,于何劳智慧?","陶渊明《桃花源诗》"] +["秋风凄切伤离,行客未归时。","温庭筠《玉蝴蝶·秋风凄切伤离》"] +["催花未歇花奴鼓,酒醒已见残红舞。","纳兰性德《菩萨蛮·催花未歇花奴鼓》"] +["但看古来歌舞地,唯有黄昏鸟雀悲。","刘希夷《代悲白头翁》"] +["爱景欲挂扶桑,漏残银箭,杓回摇斗。","李清照《长寿乐·南昌生日》"] +["晓峰如画参差碧,藤影风摇拂槛垂。","李白《别匡山》"] +["欲修其身者,先正其心","《大学·第一章》"] +["对山河百二,泪盈襟血。","王清惠《满江红·题南京夷山驿》"] +["未成林,难望凤来栖,聊医俗。","陆容《满江红·咏竹》"] +["下有青污泥,馨香无复全。","白居易《京兆府栽莲》"] +["微红夕如灭,短焰复相连。","温庭筠《烧歌》"] +["修己而不责人","《左传·闵公·闵公二年》"] +["高词迥映,如朗月之悬光;叠意回舒,若重岩之积秀。","《晋书·列传·第二十四章》"] +["祗残邺城不日得,独任朔方无限功。","杜甫《洗兵马》"] +["谁家宅第成还破,何处亲宾哭复歌?","白居易《放言五首·其四》"] +["大邦有子,伣天之妹。","佚名《大明》"] +["蝶衣晒粉花枝舞,蛛网添丝屋角晴。","张耒《夏日三首·其一》"] +["黄芦岸白蘋渡口,绿柳堤红蓼滩头。","白朴《沉醉东风·渔夫》"] +["娅姹含情娇不语,纤玉手,抚郎衣。","和凝《江城子·迎得郎来入绣闱》"] +["松竹含新秋,轩窗有余清。","崔元翰《奉和圣制中元日题奉敬寺》"] +["花树得晴红欲染,远山过雨青如滴。","吴潜《满江红·金陵乌衣园》"] +["鞉鼓渊渊,嘒嘒管声。","佚名《商颂·那》"] +["意倦须还,身闲贵早,岂为莼羹鲈脍哉。","辛弃疾《沁园春·带湖新居将成》"] +["向南楼、一声归雁。","陈亮《水龙吟·春恨》"] +["凡人之大体,取舍同者则相是也,取舍异者则相非也。","《韩非子·奸劫弑臣》"] +["不是溪山成独往,何人解作挂猿枝。","苏轼《书李世南所画秋景二首》"] +["无人解,树转午阴凉。","舒頔《小重山·端午》"] +["日月行空从地转,蛟龙入海卷潮回。","廖燕《粤王台怀古》"] +["长袖善舞,多钱善贾。","《韩非子·五蠹》"] +["荷苒苒,展凉云,横卧虹千尺。","姜夔《蓦山溪·题钱氏溪月》"] +["长想别离处,犹无音信还。","高适《自淇涉黄河途中作十三首》"] +["徐门石潭谢雨,道上作五首。","苏轼《浣溪沙·簌簌衣巾落枣花》"] +["岭上晴云披絮帽,树头初日挂铜钲。","苏轼《新城道中二首》"] +["采得百花成蜜后,为谁辛苦为谁甜。","罗隐《蜂》"] +["十二画楼帘卷,红妆笑语参差。","吴绮《春光好·迎春》"] +["十分潋滟金樽凸,千杖敲铿羯鼓催。","苏轼《有美堂暴雨》"] +["谁教岁岁红莲夜,两处沉吟各自知。","姜夔《鹧鸪天·元夕有所梦》"] +["无罪无辜,谗口嚣嚣。","佚名《十月之交》"] +["怕春波、载愁不去,恁生见得?","麦孟华《解连环·酬任公用梦窗留别石帚韵》"] +["因依老宿发心初,半学修心半读书。","王建《寄旧山僧》"] +["其仆维何?釐尔女士。","佚名《既醉》"] +["名德不昭,毁谤无损其身;义仁莫名,奸邪不以为患。","《罗织经·保身卷第七》"] +["不患人之不己知,患不知人也。","《论语·学而篇》"] +["桨声伊轧知何向?","孙光宪《渔歌子·草芊芊》"] +["空对着,山中高士晶莹雪;","曹雪芹《终身误》"] +["算平戎万里,功名本是,真儒事、君知否。","辛弃疾《水龙吟·甲辰岁寿韩南涧尚书》"] +["秋风吹地百草干,华容碧影生晚寒。","李贺《开愁歌》"] +["铫煎黄蕊色,碗转曲尘花。","元稹《一七令·茶》"] +["上兵伐谋,其次伐交,其次伐兵,其下攻城。","《孙子兵法·谋攻篇》"] +["莫道不销魂,帘卷西风,人比黄花瘦。","李清照《醉花阴·薄雾浓云愁永昼》"] +["娼家日暮紫罗裙,清歌一啭口氛氲。","卢照邻《长安古意》"] +["玉漏银壶且莫催,铁关金锁彻明开。","崔液《上元夜六首·其一》"] +["坐中人半醉,帘外雪将深。","苏轼《临江仙·冬日即事》"] +["日上花梢,莺穿柳带,犹压香衾卧。","柳永《定风波·自春来》"] +["曲终再拜谢天子,天子为之微启齿。","白居易《胡旋女》"] +["迎新在此岁,唯恐踵前迹。","柳宗元《田家三首》"] +["幸会东城宴未回,年华忧共水相催。","李商隐《可叹》"] +["夜静群动息,时闻隔林犬。","王维《春夜竹亭赠钱少府归蓝田》"] +["传闻一战百神愁,两岸强兵过未休。","曹松《己亥岁二首·僖宗广明元年》"] +["相将共、岁寒伴侣,小窗净、沉烟熏翠袂。","周密《绣鸾凤花犯·赋水仙》"] +["譬犹使人负剑,而求其寿也。","《墨子·25章 节葬(下)》"] +["心中恻,血出漉,归告我家卖黄犊。","佚名《平陵东》"] +["正漏云筛雨,斜捎窗隙。","吴文英《瑞鹤仙·秋感》"] +["叶里黄鹂时一弄。","欧阳修《渔家傲·五月榴花妖艳烘》"] +["便是君,也唤不应;便是臣,也唤不应!","张养浩《山坡羊·北邙山怀古》"] +["苏堤尽日风和雨。","刘辰翁《兰陵王·丙子送春》"] +["雨歇杨林东渡头,永和三日荡轻舟。","常建《三日寻李九庄》"] +["功名不早著,竹帛将何宣。","李白《长歌行》"] +["想佳丽,别后愁颜,镇敛眉峰。","柳永《雪梅香·景萧索》"] +["门外苍官千百辈,尽堂堂八尺须髯古。","辛弃疾《贺新郎·和徐斯远下第谢诸公载酒相访韵》"] +["百年三万六千日, 一日须倾三百杯。","李白《襄阳歌》"] +["酒圣诗豪,可能无势,我乃而今驾驭卿。","辛弃疾《沁园春·再到期思卜筑》"] +["宛其死矣,他人入室。","佚名《山有枢》"] +["敛秦烟,收楚雾,熨江流。","范成大《水调歌头·细数十年事》"] +["日中兮不来,飘风吹我裳。","繁钦《定情诗》"] +["君子有三乐,而王天下不与存焉。","《孟子·尽心章句上·第二十节》"] +["黄发台背,寿胥与试。","佚名《鲁颂·閟宫》"] +["知子之好之,杂佩以报之。","佚名《女曰鸡鸣》"] +["破青萍,排翠藻,立苍苔。","辛弃疾《水调歌头·盟鸥》"] +["独眠林下梦魂好,回首人间忧患长。","苏轼《捕蝗至浮云岭山行疲苶有怀子由弟二首·其二》"] +["小儿呼叫走长街,云有痴呆召人买。","范成大《卖痴呆词》"] +["欲笺心事,独语斜阑。","唐婉《钗头凤·世情薄》"] +["心不怡之长久兮,忧与愁其相接。","屈原《哀郢》"] +["老妻卧路啼,岁暮衣裳单。","杜甫《垂老别》"] +["歌沉玉树,古寺空有疏钟发。","李纲《六么令·次韵和贺方回金陵怀古鄱阳席上作》"] +["醉慢云鬟垂两耳。","苏轼《减字木兰花·花》"] +["端守宫闱地,寒烟朝暮平。","韦嗣立《酬崔光禄冬日述怀赠答》"] +["对此间、风物岂无情,殷勤说。","苏轼《满江红·寄鄂州朱使君寿昌》"] +["垂杨拂绿水,摇艳东风年。","李白《折杨柳》"] +["关山绝,乱云千叠,江北江南雪。","龚自珍《点绛唇·十月二日马上作》"] +["审定有无与其实虚,随其嗜欲以见其志意。","《鬼谷子·捭阖》"] +["乱洒衰荷,颗颗真珠雨。","柳永《甘草子·秋暮》"] +["凡出言 信为先 诈与妄 奚可焉","《弟子规·信》"] +["问院落凄凉,几番春暮。","赵佶《燕山亭·北行见杏花》"] +["抑若扬兮,美目扬兮。","佚名《猗嗟》"] +["看垂杨连苑,杜若侵沙,愁损未归眼。","姜夔《眉妩·戏张仲远》"] +["有华灯碍月,飞盖妨花。","秦观《望海潮·洛阳怀古》"] +["结发为夫妇,于今十七年。","梅尧臣《悼亡三首》"] +["夸赴军中宴,走马去如云。","白居易《轻肥》"] +["如彼游川鱼,比目中路析。","潘安《悼亡诗三首》"] +["犹瞻太白雪,喜遇武功天。","杜甫《喜达行在所三首》"] +["这次第,怎一个愁字了得!","李清照《声声慢·寻寻觅觅》"] +["陟彼屺兮,瞻望母兮。","佚名《陟岵》"] +["为人不做亏心事,半夜敲门心不惊。","《增广贤文·上集》"] +["且将换酒与君醉,醉归托宿吴专诸。","李白《醉后赠从甥高镇》"] +["一别隔千里,荣枯异炎凉。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["萧条清万里,瀚海寂无波。","李白《塞上曲》"] +["咸言上国繁华,岂谓帝城羁旅。","子兰《秋日思旧山》"] +["画屏残月上,篆香销。","张景祁《小重山·几点疏雅誊柳条》"] +["进帆天门山,回首牛渚没。","李白《自金陵溯流过白璧山玩月达天门寄句容王主簿》"] +["问春春道何曾去,任蜂蝶飞过东墙。","吴澄《渡江云·揭浩斋送春和韵》"] +["形容憔悴非昔悦,蓬鬓衰颜不复妆。","鲍照《拟行路难十八首》"] +["身长健,但优游卒岁,且斗尊前。","苏轼《沁园春·孤馆灯青》"] +["更复道横空清夜。","辛弃疾《贺新郎·三山雨中游西湖》"] +["芳草池塘,绿阴庭院,晚晴寒透窗纱。","李清照《转调满庭芳·芳草池塘》"] +["坎坎伐檀兮,置之河之干兮。","佚名《伐檀》"] +["狗马饰雕文,土木被缇绣。","《后汉书·列传·宦者列传》"] +["滞雨长安夜,残灯独客愁。","李商隐《滞雨》"] +["垂白杖藜抬醉眼,捋青捣麨软饥肠。","苏轼《浣溪沙·麻叶层层檾叶光》"] +["一朝去京国,十载客梁园。","李白《书情题蔡舍人雄》"] +["天一笑、满园罗绮,满城箫笛。","吴潜《满江红·金陵乌衣园》"] +["中郎旧恨,寄横竹、吹裂哀云。","吴文英《夜飞鹊·蔡司户席上南花》"] +["胡未灭,鬓先秋,泪空流。","陆游《诉衷情·当年万里觅封侯》"] +["同声相应,同气相求。","《易传·乾文言》"] +["羿淫游以佚畋兮,又好射夫封狐。","屈原《离骚》"] +["为近都门多送别,长条折尽减春风。","白居易《青门柳》"] +["新丰老翁八十八,头鬓眉须皆似雪。","白居易《新丰折臂翁》"] +["昨夜西风凋碧树,独上高楼,望尽天涯路。","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["五夜飕飗枕前觉,一年颜状镜中来。","刘禹锡《始闻秋风》"] +["新丰美酒斗十千,咸阳游侠多少年。","王维《少年行四首》"] +["曾记,曾记,人在武陵微醉。","严蕊《如梦令·道是梨花不是》"] +["待得归鞍到时,只怕春深。","姜夔《一萼红·古城阴》"] +["传有沙场千万骑,昨日边庭羽书至。","高适《送浑将军出塞》"] +["兴亡百变物自闲,富贵一朝名不朽。","苏轼《石鼓歌》"] +["化工余力染夭红。","苏轼《浣溪沙·徐州藏春阁园中》"] +["苦思搜诗灯下吟,不眠长夜怕寒衾。","鱼玄机《冬夜寄温飞卿》"] +["使君才气卷波澜。","苏轼《西江月·送别》"] +["携手藕花湖上路,一霎黄梅细雨。","朱淑真《清平乐·夏日游湖》"] +["常羡人间琢玉郎,天应乞与点酥娘。","苏轼《定风波·南海归赠王定国侍人寓娘》"] +["见梨花初带夜月,海棠半含朝雨。","万俟咏《三台·清明应制》"] +["临别殷勤重寄词,词中有誓两心知。","白居易《长恨歌》"] +["钿笼金锁睡鸳鸯,帘冷露华珠翠。","张泌《满宫花·花正芳》"] +["无聊成独卧,弹指韶光过。","纳兰性德《菩萨蛮·新寒中酒敲窗雨》"] +["山将别恨和心断,水带离声入梦流。","罗隐《魏城逢故人 / 绵谷回寄蔡氏昆仲》"] +["一轨九州,同风天下","《晋书·载记·第十三章》"] +["只有关山今夜月,千里外,素光同。","谢逸《江神子·杏花村馆酒旗风》"] +["天涯失乡路,江外老华发。","李白《江南春怀》"] +["闲倚博山长叹,泪流沾皓腕。","韦庄《归国遥·春欲晚》"] +["瞻昂昊天,曷惠其宁?","佚名《云汉》"] +["复道交窗作合欢,双阙连甍垂凤翼。","卢照邻《长安古意》"] +["借问女安居,乃在城南端。","曹植《美女篇》"] +["桂影扶疏,谁便道,今夕清辉不足?","黄庭坚《念奴娇·断虹霁雨》"] +["独睡起来情悄悄,寄愁何处好。","纳兰性德《谒金门·风丝袅》"] +["落花已逐回风去。","苏轼《木兰花令·次马中玉韵》"] +["春风十里扬州路,卷上珠帘总不如。","杜牧《赠别》"] +["当其南阳时,陇亩躬自耕。","李白《读诸葛武侯传书怀赠长安崔少府叔封昆季》"] +["别日南鸿才北去,今朝北雁又南飞。","鱼玄机《闺怨》"] +["人心似铁,官法如炉。","《增广贤文·上集》"] +["夫子秉家义,群公难与邻。","李白《赠友人三首》"] +["身如西瀼渡头云,愁抵瞿塘关上草。","陆游《木兰花·立春日作》"] +["水溢芙蓉沼,花飞桃李蹊。","薛道衡《昔昔盐》"] +["如彼萱草兮,使我忧忘。","贯休《善哉行·伤古曲无知音》"] +["婀娜随风转,金车玉作轮。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["吴生虽妙绝,犹以画工论。","苏轼《王维吴道子画》"] +["觥船一棹百分空,十岁青春不负公。","杜牧《题禅院》"] +["潜赤水兮龙吟,集珠林兮凤翥。","曹雪芹《芙蓉女儿诔》"] +["窗外一株梅,寒花五出开。","杨炯《梅花落》"] +["黄菊散芳丛,清泉凝白雪。","德隐《新秋晚眺》"] +["主人酒尽君未醉,薄暮途遥归不归。","高适《送李少府时在客舍作》"] +["圣人恃道化而不恃智巧。","《列子·说符》"] +["人亡余故宅,空有荷花生。","李白《对酒忆贺监二首》"] +["尚留顾藉,滞东风、未肯雪轻飘。","周端臣《木兰花慢·送人之官九华》"] +["腰若流纨素,耳著明月珰。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["择子莫择父,择亲莫择邻。","《增广贤文·下集》"] +["去去莫复道,沉忧令人老。","曹植《杂诗七首》"] +["欲轻肠断声,心绪乱已久。","杜甫《前出塞九首》"] +["看取晚来风势,故应难看梅花。","李清照《清平乐·年年雪里》"] +["春风吹船著吾溪,扶藜上读《中兴碑》。","黄庭坚《书摩崖碑后》"] +["靡靡逾阡陌,人烟眇萧瑟。","杜甫《北征》"] +["大弦嘈嘈如急雨,小弦切切如私语。","白居易《琵琶行 / 琵琶引》"] +["今夫地一撮土之多,及其广厚载华岳而不重,振河海而不洩,万物载焉。","《中庸·第二十六章》"] +["盘龙随镜隐,彩凤逐帷低。","薛道衡《昔昔盐》"] +["北极怀明主,南溟作逐臣。","宋之问《途中寒食题黄梅临江驿寄崔融》"] +["繁枝容易纷纷落,嫩叶商量细细开。","杜甫《江畔独步寻花七绝句》"] +["瀑布挂北斗,莫穷此水端。","李白《送王屋山人魏万还王屋·并序》"] +["见说娇颦,拥髻待君看。","辛弃疾《江神子·送元济之归豫章》"] +["老惯人间齐得丧。","陆游《青玉案·与朱景参会北岭》"] +["哀我征夫,独为匪民。","佚名《何草不黄》"] +["熊罴对我蹲,虎豹夹路啼。","曹操《苦寒行》"] +["前途倘相思,登岳一长谣。","李白《送当涂赵少府赴长芦》"] +["潇湘江上竹枝斑。","范成大《鹧鸪天·休舞银貂小契丹》"] +["念彼共人,眷眷怀顾!岂不怀归?","佚名《小明》"] +["金瑟玉壶,尽为愁媒。","李白《上崔相百忧章》"] +["疑是天边十二峰,飞入君家彩屏里。","李白《观元丹丘坐巫山屏风》"] +["如今也,不成怀抱,得似旧时那?","李清照《转调满庭芳·芳草池塘》"] +["秋色何苍然,际海俱澄鲜。","李白《秋登巴陵望洞庭》"] +["驰晖不可接,何况隔两乡?","谢朓《暂使下都夜发新林至京邑赠西府同僚》"] +["身劳而心安,为之;利少而义多,为之","《荀子·修身》"] +["一样夕阳天,留寒待禁烟。","吴锡麒《菩萨蛮·春波软荡红楼水》"] +["用下敬上,谓之贵贵;用上敬下,谓之尊贤。贵贵、尊贤,其义一也。","《孟子·万章章句下·第三节》"] +["何夜无月?何处无竹柏?但少闲人如吾两人者耳。","苏轼《记承天寺夜游 / 记承天夜游》"] +["笃志而体,君子也。","《荀子·修身》"] +["哀哉桃林战,百万化为鱼。","杜甫《潼关吏》"] +["坟垄日月多,松柏郁茫茫。","陆机《门有车马客行》"] +["香雾云鬟湿,清辉玉臂寒。","杜甫《月夜》"] +["修身以敬,勿托以尊。","《晋书·列传·第八章》"] +["在世百年,那有三万六千日之乐。","《幼学琼林·卷二·老幼寿诞》"] +["于万斯年,不遐有佐。","佚名《下武》"] +["此翁白头真可怜,伊昔红颜美少年。","刘希夷《代悲白头翁》"] +["半掬微凉,听娇蝉、声度菱唱。","吴文英《法曲献仙音·秋晚红白莲》"] +["春伴春游夜转夜。","石孝友《青玉案·征鸿过尽秋容谢》"] +["记满庭燕麦,朱扉斜阖。","吴文英《暗香·送魏句滨宰吴县解组分韵得阖字》"] +["维是褊心,是以为刺。","佚名《葛屦》"] +["天之无恩而大恩生。迅雷烈风,莫不蠢然。","《阴符经·下篇》"] +["白璧何辜,青蝇屡前。","李白《雪谗诗赠友人》"] +["回首昭阳离落日,伤心铜雀迎秋月。","文天祥《满江红·代王夫人作》"] +["和羞走,倚门回首,却把青梅嗅。","李清照《点绛唇·蹴罢秋千》"] +["胡兵出月窟,雷破关之东。","李白《流夜郎半道承恩放还兼欣克复之美书怀示息秀才》"] +["横塘双桨去如飞,何处豪家强载归。","吴伟业《圆圆曲》"] +["凡大医治病,必当安神定志,无欲无求","《千金方·诸论·论大医精诚第二》"] +["野寺分晴树,山亭过晚霞。","施闰章《山行》"] +["悄似怜、轻去瑶阙。","尹焕《霓裳中序第一·茉莉咏》"] +["民之质矣,日用饮食。","佚名《天保》"] +["造物亦知人易老,故叫江水向西流。","苏轼《八月十五日看潮五绝》"] +["凡事皆须务本。国以人为本,人以衣食为本","《贞观政要·卷八·论务农》"] +["窜身楚南极,山水穷险艰。","柳宗元《构法华寺西亭》"] +["手种堂前桃李,无限绿阴青子。","苏轼《如梦令·春思》"] +["康老胡雏,生彼月窟。","李白《上云乐》"] +["东风何时至?已绿湖上山。","丘为《题农父庐舍》"] +["炎风溽暑忽然至,羽翼脱落自摧藏。","柳宗元《笼鹰词》"] +["弦鼓一声双袖举。","白居易《胡旋女》"] +["飒飒东风细雨来,芙蓉塘外有轻雷。","李商隐《无题·飒飒东风细雨来》"] +["揆之以日,作于楚室。","佚名《鄘风·定之方中》"] +["鸳鸯绿浦上,翡翠锦屏中。","李白《长干行二首》"] +["寂寂还寂寂,出门迷所适。","李白《于五松山赠南陵常赞府》"] +["桂水澄夜氛,楚山清晓云。","范云《送沈记室夜别》"] +["但知绕树如飞鹊,不解营巢似拙鸠。","曾几《寓居吴兴》"] +["不在其位,不谋其政。","《论语·泰伯篇》"] +["农家终岁劳,至此愿稍偿。","黄燮清《秋日田家杂咏》"] +["一别如斯,落尽梨花月又西。","纳兰性德《采桑子·当时错》"] +["对失意人,莫谈得意事。处得意日,莫忘失意时。","《格言联璧·持躬类》"] +["剑是一夫用,书能知姓名。","李白《悲歌行》"] +["吴蜀风流自古同,归去应须早。","苏轼《卜算子·感旧》"] +["施惠无念,受恩莫忘。","《朱子家训·全文》"] +["朱丝弦断知音少。","辛弃疾《蝶恋花·月下醉书雨岩石浪》"] +["夭红过眼随荣谢,菊秀兰香自占春。","王汝舟《咏归堂隐鳞洞》"] +["淮水悠悠。万顷烟波万顷愁。","淮上女《减字木兰花·淮山隐隐》"] +["麒麟背上石文裂,虬龙鳞下红枝折。","李贺《昆仑使者》"] +["肇禋,迄用有成,维周之祯。","佚名《周颂·维清》"] +["今岁早梅开,依旧年时月。","张孝祥《卜算子·雪月最相宜》"] +["销魂远,千山啼祢,十里荼种。","汤恢《八声甘州·摘青梅荐酒》"] +["泛菊杯深,吹梅角远,同在京城。","刘过《柳梢青·送卢梅坡》"] +["矫矫虎臣,在泮献馘。","佚名《鲁颂·泮水》"] +["黄鸡白酒渔樵社。","贺铸《阳羡歌·山秀芙蓉》"] +["人伤则离散,农伤则国贫。","《南史·列传·卷三十九》"] +["清新庾开府,俊逸鲍参军。","杜甫《春日忆李白》"] +["谓谁朝来不作意,狂风挽断最长条。","杜甫《绝句漫兴九首》"] +["徘徊将何见?忧思独伤心。","阮籍《咏怀八十二首·其一》"] +["寄语天涯客,轻寒底用愁。","于谦《除夜太原寒甚》"] +["欲知此后相思梦,长在荆门郢树烟。","柳宗元《别舍弟宗一》"] +["视民不恌,君子是则是效。","佚名《小雅·鹿鸣》"] +["紞如三鼓,铿然一叶,黯黯梦云惊断。","苏轼《永遇乐·彭城夜宿燕子楼》"] +["轻如松花落金粉,浓似苔锦含碧滋。","李白《酬殷明佐见赠五云裘歌》"] +["呼鹰下鸟路,戏马出龙沙。","赵彦昭《奉和九日幸临渭亭登高应制》"] +["试细听、莺啼燕语。","袁去华《剑器近·夜来雨》"] +["今日何日兮,得与王子同舟。","佚名《越人歌》"] +["黄昏胡骑尘满城,欲往城南望城北。","杜甫《哀江头》"] +["奔鲸夹黄河,凿齿屯洛阳。","李白《北上行》"] +["春去也,多谢洛城人。","刘禹锡《忆江南·春去也》"] +["舒文振颓波,秉德冠彝伦。","李白《陈情赠友人》"] +["竹疏虚槛静,松密醮坛阴。","张泌《女冠子·露花烟草》"] +["迢迢五原关,朔雪乱边花。","李白《千里思》"] +["不要人夸好颜色,只流清气满乾坤。","王冕《墨梅》"] +["丰水凭川结,零露弥天凝。","陆机《梁甫吟》"] +["其奈风流端正外,更别有,系人心处。","柳永《昼夜乐·洞房记得初相遇》"] +["听风听雨过清明。愁草瘗花铭。","吴文英《风入松·听风听雨过清明》"] +["何当凌云霄,直上数千尺。","李白《南轩松》"] +["非信无以使民,非民无以守国。","《资治通鉴·周纪·周纪二》"] +["童孙未解供耕织,也傍桑阴学种瓜。","范成大《夏日田园杂兴·其七》"] +["枯桑知天风,海水知天寒。","佚名《饮马长城窟行》"] +["今古柳桥多送别,见人分袂亦愁生。","张先《江南柳·隋堤远》"] +["斜阳外,春雨足,风吹皱一池寒玉。","李致远《落梅风·斜阳外》"] +["满院黄英映酒杯。","苏轼《南乡子·席上劝李公择酒》"] +["迷时师度,悟了自度","《六祖坛经·行由·第八节》"] +["汉水旧如练,霜江夜清澄。","李白《秋夜板桥浦泛月独酌怀谢脁》"] +["弱冠弄柔翰,卓荦观群书。","左思《咏史八首·其一》"] +["醉后满身花影、倩人扶。","晏几道《虞美人·疏梅月下歌金缕》"] +["二三豪俊为时出,整顿乾坤济时了。","杜甫《洗兵马》"] +["呼儿烹鲤鱼,中有尺素书。","佚名《饮马长城窟行》"] +["人弃我取,人取我与。","《史记·货殖列传》"] +["为君一击,鹏抟九天。","李白《独漉篇》"] +["书无不可读者!","《隋书·列传·卷四十一》"] +["夕阳无限好,只是近黄昏。","李商隐《乐游原 / 登乐游原》"] +["江梅的的依茅舍,石漱溅溅漱玉沙。","张雨《喜春来·泰定三年丙寅岁除夜玉山舟中赋》"] +["春气动,草萌芽。","佚名《孤儿行》"] +["君埋泉下泥销骨,我寄人间雪满头。","白居易《梦微之》"] +["德弥盛者文弥缛,德弥彰者人弥明。","《论衡·卷二十八·书解篇》"] +["樵彼桑薪,卬烘于煁。","佚名《白华》"] +["三梁曾入用,一节奉王孙。","李贺《竹》"] +["道郎携羽扇,那日隔帘,半面曾记。","姜夔《解连环·玉鞭重倚》"] +["群胡归来血洗箭,仍唱胡歌饮都市。","杜甫《悲陈陶》"] +["此际闲愁郎不共,月移窗罅春寒重。","王士祯《蝶恋花·和漱玉词》"] +["良辰与美景,两地方虚掷。","李白《宣州九日闻崔四侍御与宇文太守游敬亭余时登响山不同此赏醉后寄崔侍御》"] +["今日龙钟人共弃,愧君犹遣慎风波。","刘长卿《江州重别薛六柳八二员外》"] +["贾傅才高,岳家军在,好勒燕然石上文。","吴文英《沁园春·送翁宾旸游鄂渚》"] +["素衣莫起风尘叹,犹及清明可到家。","陆游《临安春雨初霁》"] +["玉宇无尘,金茎有露,碧天如水。","柳永《醉蓬莱·渐亭皋叶下》"] +["骓不逝兮可奈何!虞兮虞兮奈若何!","项羽《垓下歌》"] +["时见蜂儿,仰粘轻粉,鱼吞池水。","章楶《水龙吟·燕忙莺懒芳残》"] +["叵耐灵鹊多谩语,送喜何曾有凭据?","佚名《鹊踏枝·叵耐灵鹊多谩语》"] +["南北山头多墓田,清明祭扫各纷然。","高翥《清明日对酒》"] +["我本不弃世,世人自弃我。","李白《送蔡山人》"] +["问甚时同赋,三十六陂秋色?","姜夔《惜红衣·吴兴荷花》"] +["不是望金山,我自思量禹。","辛弃疾《生查子·题京口郡治尘表亭》"] +["马上单衣寒恻恻。","姜夔《淡黄柳·空城晓角》"] +["古者圣人为猛禽狡兽暴人害民,于是教民以兵行。","《墨子·21章 节用(中)》"] +["音响一何悲!弦急知柱促。","佚名《东城高且长》"] +["蔽芾甘棠,勿翦勿拜,召伯所说。","佚名《甘棠》"] +["恐冷落、旧时心。","柳永《燕归梁·织锦裁篇写意深》"] +["仲卿闻之,亦自缢于庭树。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["矢于牧野,维予侯兴。","佚名《大明》"] +["底事闲愁,醉歌浮大白。","周密《齐天乐·清溪数点芙蓉雨》"] +["心地无非自性戒,心地无痴自性慧,心地无乱自性定","《六祖坛经·顿渐·第一节》"] +["昨夜雨疏风骤,浓睡不消残酒。","李清照《如梦令·昨夜雨疏风骤》"] +["连山变幽晦,绿水函晏温。","柳宗元《饮酒》"] +["别殿遥闻箫鼓奏。","李煜《浣溪沙·红日已高三丈透》"] +["薄晚西风吹雨到。明朝又是伤流潦。","王国维《蝶恋花·百尺朱楼临大道》"] +["粉面朱唇,一半点胭脂。","辛弃疾《江神子·赋梅寄余叔良》"] +["言必信,行必果。","《论语·子路篇》"] +["碧水惊秋,黄云凝暮,败叶零乱空阶。","秦观《满庭芳·碧水惊秋》"] +["当日九畹光风,数茎清露,纤手分花叶。","陈子龙《念奴娇·春雪咏兰》"] +["翩翩者鵻,载飞载止,集于苞杞。","佚名《四牡》"] +["常记溪亭日暮,沉醉不知归路。","李清照《如梦令·常记溪亭日暮》"] +["金天之西,白日所没。","李白《上云乐》"] +["却恨悲风时起,冉冉云间新雁,边马怨胡笳。","叶梦得《水调歌头·秋色渐将晚》"] +["香尘暗陌,华灯明昼,长是懒携手去。","刘辰翁《永遇乐·璧月初晴》"] +["千闾万井无多事,辟户开门向山翠。","韩翃《送客之江宁》"] +["薰风燕乳,暗雨梅黄,午镜澡兰帘幕。","吴文英《澡兰香·淮安重午》"] +["白头搔更短,浑欲不胜簪。","杜甫《春望》"] +["红树青山日欲斜,长郊草色绿无涯。","欧阳修《丰乐亭游春·其三》"] +["羌管一声何处曲,流莺百啭最高枝。","温庭筠《题柳》"] +["见耶背面啼,垢腻脚不袜。","杜甫《北征》"] +["君子信谗,如或酬之。","佚名《小弁》"] +["攀天莫登龙,走山莫骑虎。","李白《箜篌谣》"] +["明珠归合浦,应逐使臣星。","王维《送邢桂州》"] +["胡马依北风,越鸟巢南枝。","佚名《行行重行行》"] +["月华如水笼香砌,金环碎撼门初闭。","孙光宪《菩萨蛮·月华如水笼香砌》"] +["百末旨酒布兰生。","佚名《景星》"] +["善用兵者,不以短击长,而以长击短。","《史记·淮阴侯列传》"] +["将叔勿狃,戒其伤女。","佚名《大叔于田》"] +["渔父天寒网罟冻,莫徭射雁鸣桑弓。","杜甫《岁晏行》"] +["梅子青,梅子黄,菜肥麦熟养蚕忙。","祝允明《首夏山中行吟》"] +["驾彼四牡,四牡骙骙。","佚名《采薇》"] +["多君骋逸藻,掩映当时人。","李白《陈情赠友人》"] +["求之不可得,沿月棹歌还。","孟浩然《万山潭作》"] +["风流若未减,名与此山俱。","李白《泛沔州城南郎官湖》"] +["心知不可见,念念犹咨嗟。","李清照《晓梦》"] +["羔裘豹饰,孔武有力。","佚名《羔裘》"] +["白发空垂三千丈,一笑人间万事。","辛弃疾《贺新郎·甚矣吾衰矣》"] +["柳庭风静人眠昼,昼眠人静风庭柳。","苏轼《菩萨蛮·回文夏闺怨》"] +["索寞竟何事,徘徊只自知。","柳宗元《南涧中题》"] +["知人者智,自知者明。胜人者有力,自胜者强。知足者富。强行者有志。","《老子·道经·第三十三章》"] +["莫遣儿辈觉,此乐未渠央。","张孝祥《水调歌头·泛湘江》"] +["睡里消魂无说处,觉来惆怅消魂误。","晏几道《蝶恋花·梦入江南烟水路》"] +["驱鸡上树木,始闻叩柴荆。","杜甫《羌村三首·其三》"] +["只一事不留心,便有一事不得其理;只一物不留心,便有一物不得其所。","《格言联璧·处事类》"] +["不见当时杨柳,只是从前烟雨,磨灭几英雄。","方岳《水调歌头·平山堂用东坡韵》"] +["草秀故春色,梅艳昔年妆。","李世民《元日》"] +["天下稍安,尤须兢慎,若便骄逸,必至丧败。","《贞观政要·卷一·论政体》"] +["不愿报小怨,夜半刺私仇。","白居易《李都尉古剑》"] +["皇晋遘阳九,天下横氛雾。","江淹《刘太尉琨伤乱》"] +["云移雉尾开宫扇,日绕龙鳞识圣颜。","杜甫《秋兴八首》"] +["独无李氏灵,髣髴覩尔容。","潘安《悼亡诗三首》"] +["巨鳌未斩海水动,鱼龙奔走安得宁。","李白《猛虎行》"] +["只应守寂寞,还掩故园扉。","孟浩然《留别王侍御维 / 留别王维》"] +["九十光阴能有几?金龟解尽留无计。","朱服《渔家傲·小雨纤纤风细细》"] +["千古兴亡多少事?悠悠。不尽长江滚滚流。","辛弃疾《南乡子·登京口北固亭有怀》"] +["室空无侍者,巾屦唯挂壁。","柳宗元《赠江华长老》"] +["南内凄凉几苟活,高将军去事尤危。","黄庭坚《书摩崖碑后》"] +["□遗芳掩色,真恣凝澹,返魂骚畹。","吴文英《琐窗寒·玉兰》"] +["九江秀色可揽结,吾将此地巢云松。","李白《登庐山五老峰》"] +["姑苏城外一茅屋,万树梅花月满天。","唐寅《把酒对月歌》"] +["鹏翼垂空,笑人世,苍然无物。","辛弃疾《满江红·建康史帅致道席上赋》"] +["碧纱秋月,梧桐夜雨,几回无寐。","晏殊《撼庭秋·别来音信千里》"] +["帝里风光好,当年少日,暮宴朝欢。","柳永《戚氏·晚秋天》"] +["盖棺事则已,此志常觊豁。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["万愤结缉,忧从中催。","李白《上崔相百忧章》"] +["我隐屠钓下,尔当玉石分。","李白《赠瑕丘王少府》"] +["扫却石边云,醉踏松根月。","吴西逸《清江引·秋居》"] +["野塘烟雨罩鸳鸯。","苏庠《浣溪沙·书虞元翁书》"] +["乐只君子,遐不眉寿。","佚名《南山有台》"] +["休问梁园旧宾客,茂陵秋雨病相如。","李商隐《寄令狐郎中》"] +["不剪春衫愁意态。过收灯、有些寒在。","史达祖《夜行船·正月十八日闻卖杏花有感》"] +["将千龄兮此遇,荃何为兮独往。","徐惠《拟小山篇》"] +["两眉何处月如钩?","纳兰性德《浣溪沙·雨歇梧桐泪乍收》"] +["碧楼帘影不遮愁,还似去年今日意。","晏几道《玉楼春·东风又作无情计》"] +["东园之树,枝条载荣。","陶渊明《停云》"] +["曲栏干,为谁安?","苏轼《江城子·孤山竹阁送述古》"] +["浮生长恨欢娱少。","宋祁《玉楼春·春景》"] +["智者千虑,必有一失;愚者千虑,必有一得。","《史记·淮阴侯列传》"] +["吴越征徭非旧日,秣陵凋弊不宜秋。","李嘉祐《早秋京口旅泊章侍御寄书相问因以赠之时七夕》"] +["秋色到空闺,夜扫梧桐叶。","夏完淳《卜算子·秋色到空闺》"] +["年年春日异乡悲,杜曲黄莺可得知。","韦庄《江外思乡》"] +["不能手提天下往,何忍身去游其间?","王令《暑旱苦热》"] +["论至德者不和于俗,成大功者不谋于众。","《史记·赵世家》"] +["当时底事匆匆去?悔不载扁舟。","厉鹗《眼儿媚·一寸横波惹春留》"] +["将子无怒,秋以为期。","佚名《氓》"] +["遥忆独眠人,早寒惊梦频。","顾贞观《菩萨蛮·山城夜半催金柝》"] +["山口月欲出,先照关城楼。","岑参《初过陇山途中呈宇文判官》"] +["廿载包胥承一诺,盼乌头马角终相救。","顾贞观《金缕曲二首》"] +["沉沉心事北南东,一睨人材海内空。","龚自珍《夜坐二首》"] +["长驱渡河洛,直捣向燕幽。","岳飞《送紫岩张先生北伐》"] +["又却是,风敲竹。","苏轼《贺新郎·夏景》"] +["冰玉半湾尘不惹,停凝欲挽东流驾。","王夫之《蝶恋花·湘水经东安县东》"] +["高歌足自快,商颂有遗音。","柳宗元《觉衰》"] +["芙蓉如面柳如眉,对此如何不泪垂。","白居易《长恨歌》"] +["灭身竟不试,世义安可支!","柳宗元《哭连州凌员外司马》"] +["王事靡盬,忧我父母。","佚名《小雅·杕杜》"] +["不如轻薄儿,旦暮长相随。","李白《江夏行》"] +["有一田舍翁,偶来买花处。","白居易《买花 / 牡丹》"] +["虫兽犹知德,何况于士人。","曹植《薤露》"] +["待如今、端的约钟山,长相识。","辛弃疾《满江红·建康史帅致道席上赋》"] +["镇日无心扫黛眉。临行愁见理征衣。","夏竦《鹧鸪天·镇日无心扫黛眉》"] +["迟日明歌席,新花艳舞衣。","李白《宫中行乐词八首》"] +["故人官就此,绝境与谁同。","杜甫《送裴二虬作尉永嘉》"] +["看浩荡、千崖秋色。","刘克庄《贺新郎·九日》"] +["碧玉牙犹短,黄金缕未齐。","温庭筠《原隰荑绿柳》"] +["归来冰颗乱黏须。","苏轼《浣溪沙·覆块青青麦未苏》"] +["黄昏又听城头角,病起心情恶。","纳兰性德《虞美人·黄昏又听城头角》"] +["执豕于牢,酌之用匏。","佚名《公刘》"] +["命之不易,无遏尔躬。","佚名《文王》"] +["凌余阵兮躐余行,左骖殪兮右刃伤。","屈原《国殇》"] +["对此茫茫,不觉成长叹。","纳兰性德《点绛唇·黄花城早望》"] +["镜中聊自笑,讵是南山期。","李贺《咏怀二首》"] +["风雨瓢泉夜半,花草雪楼春到,老子已菟裘。","辛弃疾《水调歌头·送杨民瞻》"] +["慎尔言也,谓尔不信。","佚名《巷伯》"] +["朝避猛虎,夕避长蛇;磨牙吮血,杀人如麻。","李白《蜀道难》"] +["今日乐相乐,别后莫相忘。","曹植《怨歌行》"] +["风林关里水东流,白草黄榆六十秋。","张籍《凉州词三首》"] +["寻得桃源好避秦,桃红又是一年春。","谢枋得《庆庵寺桃花》"] +["草木有本心,何求美人折!","张九龄《感遇十二首·其一》"] +["感此三叹息,从师方未还。","李白《游泰山六首》"] +["近寒食雨草萋萋,著麦苗风柳映堤。","无名氏《杂诗》"] +["上谋臣以势,势不济者以术。","《罗织经·谋划卷第九》"] +["洒道轮香,润花杯满,不似前秋恶。","徐灿《念奴娇·西湖雨感次素庵韵》"] +["私自怜兮何极?心怦怦兮谅直。","宋玉《九辩》"] +["紫盖独不朝,争长嶪相望。","杜甫《望岳三首·其三》"] +["寥寥人境外,闲坐听春禽。","祖咏《苏氏别业》"] +["岁物忽如此,我来定几时。","李白《新林浦阻风寄友人》"] +["惜分长怕君先去,直待醉时休。","贺铸《眼儿媚·萧萧江上荻花秋》"] +["君子之言寡而实,小人之言多而虚。","《说苑·谈丛》"] +["须防额痒出三耳,莫放笔端风雨快。","苏轼《次韵秦太虚见戏耳聋》"] +["宽厚者,毋使人有所恃;精明者,不使人有所容。","《格言联璧·接物类》"] +["故大人之务,将在于众贤而巳。","《墨子·08章 尚贤(上)》"] +["汉开边、功名万里,甚当时、健者也曾闲。","辛弃疾《八声甘州·故将军饮罢夜归来》"] +["花近高楼伤客心,万方多难此登临。","杜甫《登楼》"] +["来相召、香车宝马,谢他酒朋诗侣。","李清照《永遇乐·落日熔金》"] +["秋到长门秋草黄。画梁双燕去,出宫墙。","薛昭蕴《小重山·秋到长门秋草黄》"] +["生刍一束,其人如玉。","佚名《白驹》"] +["圣人忘情,最下不及情;情之所钟,正在我辈。","《世说新语·伤逝》"] +["登高万井出,眺迥二流明。","王维《晓行巴峡》"] +["龟灵未免刳肠患,马失应无折足忧。","白居易《放言五首·其二》"] +["棹影斡波飞万剑,鼓声劈浪鸣千雷。","张建封《竞渡歌》"] +["遥知湖上一樽酒,能忆天涯万里人。","欧阳修《春日西湖寄谢法曹歌》"] +["乱鸦三四点,愁坐话无憀。","陈维崧《临江仙·寒柳》"] +["谒帝承明庐,逝将归旧疆。","曹植《赠白马王彪·并序》"] +["我仪图之,维仲山甫举之。","佚名《烝民》"] +["钗钿堕处遗香泽。","周邦彦《六丑·落花》"] +["断香残酒情怀恶,西风催衬梧桐落。","李清照《忆秦娥·咏桐》"] +["哀哉为犹,匪先民是程,匪大犹是经。","佚名《小旻》"] +["毕竟韶华何尝老,休道春归太遽。","俞樾《金缕曲·次女绣孙》"] +["惹长亭旧恨,几番风月。","高观国《解连环·柳》"] +["岂曰无衣?六兮。","佚名《无衣》"] +["魂来枫叶青,魂返关塞黑。","杜甫《梦李白二首·其一》"] +["今宵剩把银釭照,犹恐相逢是梦中。","晏几道《鹧鸪天·彩袖殷勤捧玉钟》"] +["无情明月,有情归梦,同到幽闺。","刘基《眼儿媚·秋思》"] +["狗马饰雕文,土木被缇绣。","《后汉书·列传·宦者列传》"] +["凤皇翼其承旗兮,高翱翔之翼翼。","屈原《离骚》"] +["我所思兮在雁门。","张衡《四愁诗》"] +["方希佐明主,长揖辞成功。","李白《东武吟》"] +["叹神游故国,花记前度。","刘辰翁《兰陵王·丙子送春》"] +["冰炭不同器而久,寒暑不兼时而至。","《韩非子·显学》"] +["重华一去宁复得,天下纷纷经几秦。","王安石《桃源行》"] +["既醉既饱,小大稽首。","佚名《楚茨》"] +["车声渐共歌声咽。","王国维《蝶恋花·满地霜华浓似雪》"] +["日夕怀空意,人谁感至精?","张九龄《感遇十二首》"] +["所思强饭食,永愿在乡里。","高适《自淇涉黄河途中作十三首》"] +["荒城自萧索,万里山河空。","王维《奉寄韦太守陟》"] +["嫩箨香苞初出林,於陵论价重如金。","李商隐《初食笋呈座中》"] +["晴野鹭鸶飞一只,水葓花发秋江碧。","皇甫松《天仙子·踯躅花开红照水》"] +["长随君,君入楚山里,云亦随君渡湘水。","李白《白云歌送刘十六归山》"] +["到处陂塘决决流,垂杨百里罨平畴。","沈德潜《过许州》"] +["刚待不思量,吹一片、箫声过墙。","项鸿祚《太常引·客中闻歌》"] +["其何能淑,载胥及溺。","佚名《桑柔》"] +["五月西施采,人看隘若耶。","李白《子夜吴歌·夏歌》"] +["幕府为才子,将军作主人。","高适《送董判官》"] +["控弦尽用阴山儿,登阵常骑大宛马。","高适《送浑将军出塞》"] +["北风吹雪四更初,嘉瑞天教及岁除。","陆游《除夜雪》"] +["溪深古雪在,石断寒泉流。","李白《寻高凤石门山中元丹丘》"] +["露坐久,疏萤时度,乌鹊正南飞。","晁端礼《绿头鸭·咏月》"] +["寂寞避暑离宫,东风辇路,芳草年年发。","萨都剌《念奴娇·登石头城次东坡韵》"] +["千秋二壮士,烜赫大梁城。","李白《侠客行》"] +["算平生肝胆,因人常热。","秋瑾《满江红·小住京华》"] +["罔敷求先王,克共明刑。","佚名《抑》"] +["当时明月,依依素影,何处飞来?","倪瓒《人月圆·伤心莫问前朝事》"] +["衰柳啼鸦,惊风驱雁,动人一片秋声。","周邦彦《庆春宫·云接平冈》"] +["可以荐嘉客⒀,奈何阻重深。","张九龄《感遇十二首》"] +["燕燕尔勿悲,尔当返自思。","白居易《燕诗示刘叟》"] +["影飘垂叶外,香度落花前。","王勃《圣泉宴》"] +["款曲鸡黍期,酸辛别离袂。","高适《赠别王十七管记》"] +["我客戾止,永观厥成。","佚名《周颂·有瞽》"] +["携盘独出月荒凉,渭城已远波声小。","李贺《金铜仙人辞汉歌》"] +["道休孤密约,鉴取深盟。","纳兰性德《红窗月·燕归花谢》"] +["鸡鸣钟动百鸟散,船头击鼓还相呼。","苏轼《舟中夜起》"] +["莫言圣主长不用,其那苍生应未休。","岑参《客舍悲秋有怀两省旧游呈幕中诸公》"] +["摇落暮天迥,青枫霜叶稀。","刘长卿《馀干旅舍》"] +["言不尽,观顿首。","顾贞观《金缕曲二首》"] +["相伊归去后,应似我情怀。","苏轼《临江仙·昨夜渡江何处宿》"] +["奕奕寝庙,君子作之。","佚名《巧言》"] +["金河秋半虏弦开,云外惊飞四散哀。","杜牧《早雁》"] +["欢娱未终朝,秩满归咸阳。","李白《经乱离后天恩流夜郎忆旧游书怀赠江夏韦太守良宰》"] +["春雨断桥人不渡,小舟撑出柳阴来。","徐俯《春游湖》"] +["渐红湿杏泥,愁燕无语。","吴文英《扫花游·西湖寒食》"] +["似花还似非花,也无人惜从教坠。","苏轼《水龙吟·次韵章质夫杨花词》"] +["遮莫枝根长百丈,不如当代多还往。","李白《少年行三首·其三》"] +["今日山城对垂泪,伤心不独为悲秋。","李益《上汝州郡楼》"] +["沔彼流水,朝宗于海。","佚名《沔水》"] +["十二楼中尽晓妆,望仙楼上望君王。","薛逢《宫词》"] +["辞山不忍听,挥策还孤舟。","李白《自巴东舟行经瞿唐峡登巫山最高峰晚还题壁》"] +["蓼岸风多橘柚香。","孙光宪《浣溪沙·蓼岸风多橘柚香》"] +["天涯一去无穷已,蓟门迢递三千里。","卢思道《从军行》"] +["遥欣克复美,光武安可同。","李白《流夜郎半道承恩放还兼欣克复之美书怀示息秀才》"] +["不斩奸邪恨最深!","宇文虚中《在金日作·选二》"] +["黄昏又听城头角,病起心情恶。","纳兰性德《虞美人·黄昏又听城头角》"] +["小处不渗漏,暗处不欺隐,末路不怠荒,才是真正英雄。","《菜根谭·概论》"] +["爝火燃回春浩浩,洪炉照破夜沉沉。","于谦《咏煤炭》"] +["山口月欲出,先照关城楼。","岑参《初过陇山途中呈宇文判官》"] +["关西老将能苦战,七十行兵仍未休。","岑参《胡歌》"] +["多君枉高驾,赠我以微言。","李白《别韦少府》"] +["梦泽吞楚大,闽山厄海丛。","贾岛《寄朱锡珪》"] +["烈士暮年,壮心不已。","曹操《龟虽寿》"] +["去岁别舂陵,沿流此投迹。","柳宗元《赠江华长老》"] +["深院无人帘幕垂,玉英翠羽灿芳枝。","李纲《渡海至琼管天宁寺咏阇提花三首》"] +["庾信文章老更成,凌云健笔意纵横。","杜甫《戏为六绝句》"] +["自从献宝朝河宗,无复射蛟江水中。","杜甫《韦讽录事宅观曹将军画马图》"] +["寥寥人境外,闲坐听春禽。","祖咏《苏氏别业》"] +["昨宵庭外悲歌发,知是花魂与鸟魂。","曹雪芹《葬花吟》"] +["怕一夕西风,井梧吹碧。","周密《齐天乐·清溪数点芙蓉雨》"] +["荷叶罗裙一色裁,芙蓉向脸两边开。","王昌龄《采莲曲》"] +["月光欲到长门殿,别作深宫一段愁。","李白《长门怨二首》"] +["心不可太大,心太大,则舍近图远,难期有成矣。","《围炉夜话·第三十则》"] +["长风挂席势难回,海动山倾古月摧。","李白《永王东巡歌十一首》"] +["故如比目鱼,今隔如参辰。","徐干《室思》"] +["若有人知春去处。","黄庭坚《清平乐·春归何处》"] +["好为庐山谣,兴因庐山发。","李白《庐山谣寄卢侍御虚舟》"] +["吹到一片秋香,清辉了如雪。","纳兰性德《琵琶仙·中秋》"] +["尝闻求福木居士,试向艾人成祝呵。","赵蕃《端午三首》"] +["沛然乘天游,独往失所在。","李白《送王屋山人魏万还王屋·并序》"] +["宁期此地忽相遇,惊喜茫如堕烟雾。","李白《江夏赠韦南陵冰》"] +["天寒路滑马蹄僵,元是王郎,来送刘郎。","刘克庄《一剪梅·余赴广东实之夜饯于风亭》"] +["万里不惜死,一朝得成功。","高适《塞下曲》"] +["黄鸡白酒,君去村社一番秋。","辛弃疾《水调歌头·送杨民瞻》"] +["慨当以慷,忧思难忘。","曹操《短歌行》"] +["几度飞来活捉取,锁上金笼休共语。","佚名《鹊踏枝·叵耐灵鹊多谩语》"] +["七月七,回首笑三郎。","佚名《喜春来·七夕》"] +["丹青不知老将至,富贵于我如浮云。","杜甫《丹青引赠曹将军霸》"] +["一捻残香,拈插莓苔隙。","王夫之《蝶恋花·湘水经东安县东》"] +["昨来补卫士,唯用筒竹布。","刘禹锡《插田歌》"] +["玉帐空分垒,金笳己罢吹。","纳兰性德《南歌子·古戍饥乌集》"] +["奢者富而不足,何如俭者贫而有余。","《菜根谭·概论》"] +["下谋上以术,术有穷者以力。","《罗织经·谋划卷第九》"] +["鸣蝉更乱行人耳,正抱疏桐叶半黄。","王安石《葛溪驿》"] +["寻元存终古,洞往想逸民。","支遁《咏利城山居》"] +["空余旧迹郁苍苍,雾沈半垒。","周邦彦《西河·大石金陵》"] +["君子有酒,酌言献之。","佚名《瓠叶》"] +["出其所不趋,趋其所不意。","《孙子兵法·虚实篇》"] +["善删者字去而意留,善敷者辞殊而义显。","《文心雕龙·熔裁》"] +["渔阳豪侠地,击鼓吹笙竽。","杜甫《后出塞五首》"] +["出浴太真冰作影,捧心西子玉为魂。","曹雪芹《咏白海棠》"] +["萧曹曾作沛中吏,攀龙附凤当有时。","李白《猛虎行》"] +["贤哲栖栖古如此,今时亦弃青云士。","李白《猛虎行》"] +["乱条犹未变初黄,倚得东风势便狂。","曾巩《咏柳》"] +["不然拂剑起,沙漠收奇勋。","李白《赠何七判官昌浩》"] +["忽反顾以游目兮,将往观乎四荒。","屈原《离骚》"] +["好风二十四花期。","赵溍《临江仙·西湖春泛》"] +["北邙未省留闲地,东海何曾有顶波。","白居易《放言五首·其四》"] +["留连弥信宿,此欢难可过。","张华《轻薄篇》"] +["夫众喣漂山,聚蚊成雷","《汉书·传·景十三王传》"] +["复道交窗作合欢,双阙连甍垂凤翼。","卢照邻《长安古意》"] +["安上在于悦下,为己存乎利人。","《晋书·列传·第二十四章》"] +["明日潮来无定准。","苏轼《渔家傲·送台守江郎中》"] +["又恐被、秋风惊绿。","苏轼《贺新郎·夏景》"] +["怡然敬父执,问我来何方。","杜甫《赠卫八处士》"] +["海外徒闻更九州,他生未卜此生休。","李商隐《马嵬二首》"] +["云间笑语,使君高会,佳人半醉。","苏轼《水龙吟·黄州梦过栖霞楼》"] +["素肌应怯余寒,艳阳占立青芜地。","周邦彦《水龙吟·梨花》"] +["目穷淮海满如银,万道虹光育蚌珍。","米芾《中秋登楼望月》"] +["白羽落酒樽,洞庭罗三军。","李白《九日登巴陵置酒望洞庭水军》"] +["一朝被谗言,二桃杀三士。","佚名《梁甫吟》"] +["不侮矜寡,不畏强御。","佚名《烝民》"] +["离歌且莫翻新阕,一曲能教肠寸结。","欧阳修《玉楼春·尊前拟把归期说》"] +["谁念我,今无裳?","史达祖《寿楼春·寻春服感念》"] +["斑白居上列,酒酣进庶羞。","杜甫《后出塞五首》"] +["东风吹我过湖船。杨柳丝丝拂面。","张孝祥《西江月·问讯湖边春色》"] +["明知边地苦,贱妾何能久自全?","陈琳《饮马长城窟行》"] +["高会聊追短景,清商不暇馀妍。","苏轼《雨中花·夜行船》"] +["友者、所以相有也。道不同,何以相有也?","《荀子·大略》"] +["怪此秃谁使,乡人语其由。","王安石《秃山》"] +["与肩挑贸易,毋占便宜;见贫苦亲邻,须加温恤。","《朱子家训·全文》"] +["缧囚终老无余事,愿卜湘西冉溪地。","柳宗元《冉溪》"] +["何为出战辄披靡,传置荔枝多马死。","李清照《浯溪中兴颂诗和张文潜二首》"] +["素丝祝之,良马六之。","佚名《干旄》"] +["可怜身上衣正单,心忧炭贱愿天寒。","白居易《卖炭翁》"] +["黄昏庭院柳啼鸦,记得那人,和月折梨花。","陈亮《虞美人·东风荡飏轻云缕》"] +["侯门辞必服,忍位取悲增。","柳宗元《送元暠师诗》"] +["前度书多隐语,意浅愁难答。","晏几道《六幺令·绿阴春尽》"] +["上帝甚蹈,无自暱焉。","佚名《菀柳》"] +["十年驱驰海色寒,孤臣于此望宸銮。","戚继光《望阙台》"] +["新丰老翁八十八,头鬓眉须皆似雪。","白居易《新丰折臂翁》"] +["事去千年犹恨速,愁来一日即为长。","李益《同崔邠登鹳雀楼》"] +["料浅雪、黄昏驿路,飞香遗冻草。","吴文英《花犯·谢黄复庵除夜寄古梅枝》"] +["日观东北倾,两崖夹双石。","李白《游泰山六首》"] +["堪对此景,争忍独醒归去。","柳永《迎新春·嶰管变青律》"] +["平山栏槛倚晴空,山色有无中。","欧阳修《朝中措·平山堂》"] +["濯鳞沧海畔,驰骋大漠中。","张华《壮士篇》"] +["满地残红宫锦污,昨夜南园风雨。","王安国《清平乐·春晚》"] +["不信请看弈棋者,输赢须待局终头。","白居易《放言五首·其二》"] +["原浊者,流不清;行不信者,名必耗。","《墨子·02章 修身》"] +["杭苇岸才登,行入乱峰层碧。","周之琦《好事近·杭苇岸才登》"] +["胡人落泪沾边草,汉使断肠对归客。","李颀《听董大弹胡笳声兼寄语弄房给事》"] +["余发种种如是,此事付渠侬。","辛弃疾《水调歌头·我饮不须劝》"] +["日落山之幽,临风望羽客。","杨素《山斋独坐赠薛内史》"] +["竹帛烟销帝业虚,关河空锁祖龙居。","章碣《焚书坑》"] +["为君愁绝听鸣橹。","杨炎正《蝶恋花·别范南伯》"] +["真珠小娘下清廓,洛苑香风飞绰绰。","李贺《洛姝真珠》"] +["近来云雨忽西东。","柳永《集贤宾·小楼深巷狂游遍》"] +["天地合而万物生,阴阳接而变化起,性伪合而天下治。","《荀子·礼论》"] +["晓雾将歇,猿鸟乱鸣;","陶弘景《答谢中书书》"] +["惨惨时节尽,兰叶复凋零。","徐干《室思》"] +["龟游莲叶上,鸟宿芦花里。","李白《姑孰十咏》"] +["犀渠玉剑良家子,白马金羁侠少年。","卢思道《从军行》"] +["更无柳絮因风起,惟有葵花向日倾。","司马光《客中初夏》"] +["春去花还在,人来鸟不惊。","王维《画》"] +["消息未知归早晚。","晏殊《蝶恋花·帘幕风轻双语燕》"] +["重来花畔倚阑干,愁满阑干无倚处。","周端臣《玉楼春·华堂帘幕飘香雾》"] +["梦破南楼,绿云堆一枕。","陆游《清商怨·葭萌驿作》"] +["思君若汶水,浩荡寄南征。","李白《沙丘城下寄杜甫》"] +["已是难听,中宵无用怨离别。","厉鹗《齐天乐·秋声馆赋秋声》"] +["固时俗之工巧兮,偭规矩而改错。","屈原《离骚》"] +["肠断江春欲尽头,杖藜徐步立芳洲。","杜甫《绝句漫兴九首》"] +["何惭宿昔意,猜恨坐相仍。","鲍照《代白头吟》"] +["射有似乎君子。失诸正鹄,反求诸其身。","《中庸·第十四章》"] +["彼谮人者,谁适与谋?","佚名《巷伯》"] +["韩信羞将绛灌比,祢衡耻逐屠沽儿。","李白《答王十二寒夜独酌有怀》"] +["美酒清歌,留连不住,月随人千里。","苏轼《永遇乐·长忆别时》"] +["昨晚西窗风料峭,又把黄梅瘦了。","吴绮《惜分飞·寒夜》"] +["如今熟计,只有故乡归路。","陆游《感皇恩·小阁倚秋空》"] +["烟隔断、晴波远岫。","吴文英《夜游宫·春语莺迷翠柳》"] +["夫民劳则思,思则善心生;逸则淫,淫则忘善,忘善之恶心生。","《国语·鲁语·公父文伯之母论劳逸》"] +["敕赐镜湖水,为君台沼荣。","李白《对酒忆贺监二首》"] +["辇前才人带弓箭,白马嚼啮黄金勒。","杜甫《哀江头》"] +["忽如朝玉皇,天冕垂前旒。","柳宗元《界围岩水帘》"] +["绛帻鸡人送晓筹,尚衣方进翠云裘。","王维《和贾舍人早朝大明宫之作》"] +["黄四娘家花满蹊,千朵万朵压枝低。","杜甫《江畔独步寻花七绝句》"] +["愁难谱,只彩线、香菰脉脉成千古。","纳兰性德《摸鱼儿·午日雨眺》"] +["怀乡访古事悠悠,独上江城满目秋。","王珪《金陵怀古》"] +["不如子之衣,安且吉兮。","佚名《无衣》"] +["北转河流,南横斗柄,略点微霜鬓早衰。","纳兰性德《沁园春·试望阴山》"] +["断续凉云来一缕,飘堕几丝灵雨。","纳兰性德《河渎神·风紧雁行高》"] +["千古江山,英雄无觅,孙仲谋处。","辛弃疾《永遇乐·京口北固亭怀古》"] +["参差烟树灞陵桥,风物尽前朝。","柳永《少年游·参差烟树灞陵桥》"] +["鸳鸯于飞,毕之罗之。","佚名《鸳鸯》"] +["万里他乡,非生非死,此身良苦。","纳兰性德《水龙吟·题文姬图》"] +["逍遥莫谁睹,望君愁我肠。","繁钦《定情诗》"] +["瓶沉簪折知奈何?似妾今朝与君别。","白居易《井底引银瓶·止淫奔也》"] +["追念照水然犀,男儿当似此,英碓豪杰。","吴渊《念奴娇·我来牛渚》"] +["素衣朱绣,从子于鹄。","佚名《唐风·扬之水》"] +["天之降罔,维其优矣。","佚名《大雅·瞻卬》"] +["朝扣富儿门,暮随肥马尘。","杜甫《奉赠韦左丞丈二十二韵》"] +["采药衔杯愁满满,折花行令笑迟迟。","陶弼《南池》"] +["波声拍枕长淮晓,隙月窥人小。","苏轼《虞美人·波声拍枕长淮晓》"] +["笑人间、何处似尊前,添银烛。","范成大《满江红·清江风帆甚快作此与客剧饮歌之》"] +["欲填沟壑唯疏放,自笑狂夫老更狂。","杜甫《狂夫》"] +["春山碧树秋重绿,人在武陵溪。","刘基《眼儿媚·秋思》"] +["成大功者不小苛。","《说苑·政理》"] +["惟心会而不可口传,可神通而不可语达。","《红楼梦·第五回》"] +["剑南春色还无赖,触忤愁人到酒边。","杜甫《送路六侍御入朝》"] +["上慎旃哉,犹来!","佚名《陟岵》"] +["山花异人间,五月雪中白。","李白《游泰山六首》"] +["大别我知友,突兀起西州。","王以宁《水调歌头·呈汉阳使君》"] +["既来不须臾,又不处重闱。","佚名《凛凛岁云暮》"] +["寒声隐地,初听中夜入梧桐。","叶梦得《水调歌头·九月望日与客习射西园余偶病不能射》"] +["问元戎早晚,鸣鞭径去,解天山箭。","元好问《水龙吟·从商帅国器猎于南阳同仲泽鼎玉赋此》"] +["为谢西园车马客,定悲摇落尽成空。","李商隐《题小松》"] +["断鸿声里,立尽斜阳。","柳永《玉蝴蝶·望处雨收云断》"] +["皎皎白林秋,微微翠山静。","陈子昂《酬晖上人秋夜山亭有赠》"] +["不恨天涯行役苦,只恨西风,吹梦成今古。","纳兰性德《蝶恋花·又到绿杨曾折处》"] +["但愿老死花酒间,不愿鞠躬车马前。","唐寅《桃花庵歌》"] +["春伴春游夜转夜。","石孝友《青玉案·征鸿过尽秋容谢》"] +["或降于阿,或饮于池,或寝或讹。","佚名《无羊》"] +["一向花前看白发,几回梦里忆红颜。","卢僎《十月梅花书赠》"] +["记取钗头新利市,莫将分付东邻子。","苏轼《蝶恋花·送潘大临》"] +["可怜汾上柳,相见也依依。","岑参《题平阳郡汾桥边柳树》"] +["东方岁星大明宫,南山喜气摇晴空。","鲍防《元日早朝行》"] +["长吟掩柴门,聊为陇亩民。","陶渊明《癸卯岁始春怀古田舍二首》"] +["《阳春》之曲,和者必寡,盛名之下,其实难副。","《后汉书·列传·左周黄列传》"] +["不须横管吹江郭,最惜空枝冷夕曛。","律然《落梅》"] +["稍闻决决流冰谷,尽放青青没烧痕。","苏轼《正月二十日往岐亭郡人潘古郭三人送余于女王城东禅庄院》"] +["彼人是哉,子曰何其?","佚名《园有桃》"] +["高议而不可及,不若卑论之有功也。","《说苑·谈丛》"] +["山光忽西落,池月渐东上。","孟浩然《夏日南亭怀辛大》"] +["世间珍果更无加,玉雪肌肤罩绛纱。","丘浚《咏荔枝》"] +["人品之不高,总为一利字看不破;学业之不进,总为一懒字丢不开。","《围炉夜话·第十六则》"] +["时无重至,华不再阳。","陆机《短歌行》"] +["天下之治乱,不在一姓之兴亡,而在万民之忧乐。","《明夷待访录·原臣》"] +["请君试问东流水,别意与之谁短长。","李白《金陵酒肆留别》"] +["壁空残月曙,门掩候虫秋。","柳宗元《酬娄秀才寓居开元寺早秋月夜病中见寄》"] +["归去来兮虽得归,念归政自莫轻违。","胡铨《送菊》"] +["留人不住,醉解兰舟去。","晏几道《清平乐·留人不住》"] +["寒螀爱碧草,鸣凤栖青梧。","李白《陌上桑》"] +["料黛眉重锁隋堤,芳心还动梁苑。","卢祖皋《宴清都·初春》"] +["临蒸且莫叹炎方,为报秋来雁几行。","柳宗元《得卢衡州书因以诗寄》"] +["宝书长剑挂高阁,金鞍骏马散故人。","李白《猛虎行》"] +["长风吹白茅,野火烧枯桑。","岑参《至大梁却寄匡城主人》"] +["画栋朝飞南浦云,珠帘暮卷西山雨。","王勃《滕王阁诗》"] +["长干白下,青楼朱阁,往往梦中槐蚁。","姜夔《永遇乐·次韵辛克清先生》"] +["西风梨枣山园,儿童偷把长竿。","辛弃疾《清平乐·检校山园书所见》"] +["揽弓捷鸣镝,长驱上南山。","曹植《名都篇》"] +["但将酩酊酬佳节,不用登临恨落晖。","杜牧《九日齐山登高 / 九日齐安登高》"] +["海阔山遥,未知何处是潇湘。","柳永《玉蝴蝶·望处雨收云断》"] +["但欲掩昏旦,遂复经圆缺。","谢灵运《登庐山绝顶望诸峤》"] +["知己知彼,将心比心。","《增广贤文·上集》"] +["方苞方体,维叶泥泥。","佚名《行苇》"] +["江潮容易得,只是人南北。","舒亶《菩萨蛮·画船捶鼓催君去》"] +["老妻书数纸,应悉未归情。","杜甫《客夜》"] +["飞魂同夜鹊,倦寝忆晨鸡。","薛道衡《昔昔盐》"] +["小立背西风,又是重门掩。","杨无咎《生查子·秋来愁更深》"] +["迢递路回清野,人语渐无闻,空带愁归。","周邦彦《夜飞鹊·河桥送人处》"] +["花落子规啼,绿窗残梦迷。","温庭筠《菩萨蛮·玉楼明月长相忆》"] +["花非花,雾非雾。","白居易《花非花》"] +["卷絮风头寒欲尽。","赵令畤《蝶恋花·卷絮风头寒欲尽》"] +["矫菌桂以纫蕙兮,索胡绳之纚纚。","屈原《离骚》"] +["譬如朝露,去日苦多。","曹操《短歌行》"] +["月斜西院愈声悲。","晁补之《临江仙·信州作》"] +["想得玉人情,也合思量我。","孙光宪《生查子·窗雨阻佳期》"] +["忆共人人睡魂蝶乱,梦鸾孤。","赵长卿《更漏子·烛消红》"] +["君不见春鸟初至时,百草含青俱作花。","鲍照《拟行路难十八首》"] +["楫齐杨以容与兮,哀见君而不再得。","屈原《哀郢》"] +["游女带花偎伴笑,争窈窕,竞折团荷遮晚照。","李珣《南乡子·乘彩舫》"] +["酒债寻常行处有,人生七十古来稀。","杜甫《曲江二首》"] +["质胜文则野,文胜质则史,文质彬彬,然后君子。","《论语·雍也篇》"] +["烟里歌声隐隐,渡头月色沉沉。","鱼玄机《隔汉江寄子安》"] +["牧羊驱马虽戎服,白发丹心尽汉臣。","杜牧《河湟》"] +["尊前和笑不成歌,意偷转、眼波微送。","谢绛《夜行船·别情》"] +["更红鲜冷淡不成圆,樱桃颗。","郑燮《满江红·思家》"] +["梁甫吟,声正悲。","李白《梁甫吟》"] +["水晶楼下欲三更。","张元干《浣溪沙·山绕平湖波撼城》"] +["明年十月东都破,御路犹存禄山过。","元稹《连昌宫词》"] +["说与折花人道,须插向、鬓边斜。","蒋捷《霜天晓角·人影窗纱》"] +["厌浥行露,岂不夙夜,谓行多露。","佚名《行露》"] +["今年宜未衰,稍已来相寻。","柳宗元《觉衰》"] +["知不知,上矣。过者之患,不知而自以为知。","《吕氏春秋·论·似顺论》"] +["十月三千里,郎行几岁归。","李白《巴女词》"] +["抽刀断水水更流,举杯销愁愁更愁。","李白《宣州谢脁楼饯别校书叔云》"] +["一寸狂心未说,已向横波觉。","晏几道《六幺令·绿阴春尽》"] +["萁在釜下燃,豆在釜中泣。","曹植《七步诗》"] +["君子于役,如之何勿思!","佚名《君子于役》"] +["风烟俱净,天山共色。","吴均《与朱元思书》"] +["大丈夫当横尸战场,奈何狼藉都市。","《南史·列传·卷十九》"] +["钓台碧云中,邈与苍岭对。","李白《送王屋山人魏万还王屋·并序》"] +["戎车既安,如轾如轩。","佚名《六月》"] +["亭上秋风,记去年袅袅,曾到吾庐。","辛弃疾《汉宫春·会稽秋风亭观雨》"] +["张翰江东去,正值秋风时。","李白《送张舍人之江东》"] +["当初不合轻分散。","柳永《安公子·梦觉清宵半》"] +["正门外、催人起。","程垓《酷相思·月挂霜林寒欲坠》"] +["残日东风,不放岁华去。","吴文英《祝英台近·除夜立春》"] +["困倚危楼,过尽飞鸿字字愁。","秦观《减字木兰花·天涯旧恨》"] +["来往不逢人,长歌楚天碧。","柳宗元《溪居》"] +["求田问舍笑豪英。","苏轼《南歌子·再用前韵》"] +["石壁望松寥,宛然在碧霄。","李白《焦山望寥山》"] +["遗我鸟迹书,飘然落岩间。","李白《游泰山六首》"] +["峥嵘甚,讶雪山中断,银汉西倾。","吴伟业《沁园春·观潮》"] +["最可惜、一片江山,总付与啼鴂。","姜夔《八归·湘中送胡德华》"] +["三军可夺帅也,匹夫不可夺志也。","佚名《论语十二章》"] +["不知香积寺,数里入云峰。","王维《过香积寺》"] +["原一见兮道余意,君之心兮与余异。","宋玉《九辩》"] +["白日丽飞甍,参差皆可见。","谢朓《晚登三山还望京邑》"] +["善用兵者,避其锐气,击其惰归。","《孙子兵法·军争篇》"] +["被服罗裳衣,当户理清曲。","佚名《东城高且长》"] +["长绳百尺拽碑倒,粗砂大石相磨治。","李商隐《韩碑》"] +["靡有旅力,以念穹苍。","佚名《桑柔》"] +["圆光含万象,碎影入闲流。","王维《赋得秋日悬清光》"] +["敬天之怒,无敢戏豫。","佚名《板》"] +["父母生我,胡俾我瘉?","佚名《正月》"] +["思君见巾栉,以益我劳勤。","徐干《室思》"] +["非鬼亦非仙,一曲桃花水。","辛弃疾《生查子·独游雨岩》"] +["浮世本来多聚散,红蕖何事亦离披?","李商隐《七月二十九日崇让宅宴作》"] +["马上相逢无纸笔,凭君传语报平安。","岑参《逢入京使》"] +["顺风波以从流兮,焉洋洋而为客。","屈原《哀郢》"] +["愚生三季后,慨然念黄虞。","陶渊明《赠羊长史·并序》"] +["万里风烟,一溪霜月,未怕欺他得。","辛弃疾《念奴娇·梅》"] +["前度刘郎,几许风流地,花也应悲。","韩元吉《六州歌头·东风著意》"] +["东风不解愁,偷展湘裙衩。","纳兰性德《生查子·东风不解愁》"] +["檐飞宛溪水,窗落敬亭云。","李白《过崔八丈水亭》"] +["忙忙如丧家之狗,急急如漏网之鱼。","《醒世恒言·卷十七》"] +["雨打芙蓉泪不干。","秦观《丑奴儿·夜来酒醒清无梦》"] +["一样夕阳天,留寒待禁烟。","吴锡麒《菩萨蛮·春波软荡红楼水》"] +["节彼南山,有实其猗。","佚名《节南山》"] +["尚想横汾,兰菊纷相半。","苏轼《点绛唇·庚午重九再用前韵》"] +["谁教风鉴在尘埃?","苏轼《虞美人·波声拍枕长淮晓》"] +["鱼离水则身枯,心离书则神索。","《格言联璧·学问类》"] +["有一言,无一行,胃(谓)之诬。","《黄帝四经·十大经·雌雄节》"] +["西塞山边白鹭飞,散花洲外片帆微。","苏轼《浣溪沙·渔父》"] +["还凭流水送人归。","苏轼《临江仙·风水洞作》"] +["独抱绿绮琴,夜行青山间。","李白《游泰山六首》"] +["老来情味减,对别酒,怯流年。","辛弃疾《木兰花慢·滁州送范倅》"] +["我志在删述,垂辉映千春。","李白《古风·大雅久不作》"] +["哀哉两决绝,不复同苦辛。","杜甫《前出塞九首》"] +["二年寄迹闽山寺,一笑翻然向浙江。","刘子翚《绝句送巨山》"] +["贫贱非辱,贫贱而谄求于人者为辱;富贵非荣,富贵而利济于世者为荣。","《围炉夜话·第三一则》"] +["茂苑城如画,阊门瓦欲流。","李商隐《陈后宫》"] +["挥羽扇,整纶巾,少年鞍马尘。","辛弃疾《阮郎归·耒阳道中为张处父推官赋》"] +["漏清宫树子规啼,愁锁碧窗春晓。","尹鹗《满宫花·月沉沉》"] +["归来觊命驾,沐浴休玉堂。","杜甫《望岳三首·其三》"] +["德者事业之基,未有基不固而栋宇坚久者","《菜根谭·概论》"] +["日斜江上孤帆影,草绿湖南万里情。","刘长卿《别严士元》"] +["对酒叙长篇,穷途运命委皇天。","鲍照《拟行路难十八首》"] +["无求到处人情好,不饮任他酒价高。","《增广贤文·上集》"] +["仙人有待乘黄鹤,海客无心随白鸥。","李白《江上吟》"] +["半夜寻幽上四明,手攀松桂触云行。","施肩吾《同诸隐者夜登四明山》"] +["穿天透地不辞劳,到底方知出处高。","冯云山《咏瀑布》"] +["君子不重则不威,学则不固。主忠信,无友不如己者,过则勿惮改。","《论语·学而篇》"] +["终然不受赏,羞与时人同。","李白《五月东鲁行答汶上君》"] +["琵琶一曲肠堪断,风萧萧兮夜漫漫。","岑参《凉州馆中与诸判官夜集》"] +["我所思兮在雁门。","张衡《四愁诗》"] +["临行密密缝,意恐迟迟归。","孟郊《游子吟》"] +["难道天公,还箝恨口,不许长吁一两声?","郑板桥《沁园春·恨》"] +["流沙丹灶灭,关路紫烟沉。","李白《谒老君庙》"] +["忆昔吞悲别亲族,扶入车中不教哭。","白居易《上阳白发人》"] +["谁能春独愁,对此径须饮。","李白《月下独酌四首》"] +["故巢似与人留恋。","梁启超《金缕曲·丁未五月归国旋复东渡却寄沪上诸君子》"] +["须教镂花拨柳,争要先看。","王观《庆清朝慢·踏青》"] +["千里曜戈甲,万灶宿貔貅。","陆游《水调歌头·多景楼》"] +["涨西湖、半篙新雨,麯尘波外风软。","张翥《摸鱼儿·送春》"] +["孤灯暧不明,寒机晓犹织。","沈约《夜夜曲》"] +["纵使画工奇妙也缩手,我爱清香故把新诗嘲。","明本《九字梅花咏》"] +["涉有本非取,照空不待析。","柳宗元《巽公院五咏》"] +["乃觏于京,京师之野。","佚名《公刘》"] +["短长亭子短长桥,桥外垂杨一万条。","钱枚《忆王孙·短长亭子短长桥》"] +["纤云散,耿耿素娥欲下。","周邦彦《解语花·上元》"] +["苹以春晖,兰以秋芳。","陆机《短歌行》"] +["方当销锋散马牛,旋师宴镐京。","杨广《纪辽东二首》"] +["公道世间唯白发,贵人头上不曾饶。","杜牧《送隐者一绝》"] +["故交吾未测,薄宦空年岁。","高适《赠别王十七管记》"] +["唯有南风旧相识,偷开门户又翻书。","刘攽《新晴》"] +["浅把涓涓酒,深凭送此生。","杜甫《水槛遣心二首》"] +["孤花片叶,断送清秋节。","纳兰性德《清平乐·孤花片叶》"] +["以小人尚民而威,以非所取于民而巧,是伤国之大灾也。","《荀子·王霸》"] +["不为寻春较远,辜负春阑。","张惠言《风流子·出关见桃花》"] +["对千里寒光,念幽期阻、当残景。","柳永《倾杯·金风淡荡》"] +["离恨远萦杨柳,梦魂长绕梨花。","刘迎《乌夜啼·离恨远萦杨柳》"] +["魂来枫叶青,魂返关塞黑。","杜甫《梦李白二首·其一》"] +["莫嫌旧日云中守,犹堪一战取功勋。","王维《老将行》"] +["谪弃殊隐沦,登陟非远郊。","柳宗元《游朝阳岩遂登西亭二十韵》"] +["鸡鸣狗吠,兄嫂当知之。","佚名《有所思》"] +["骅骝拳跼不能食,蹇驴得志鸣春风。","李白《答王十二寒夜独酌有怀》"] +["或云:尧幽囚,舜野死。","李白《远别离》"] +["芳草有情皆碍马,好云无处不遮楼。","罗隐《魏城逢故人 / 绵谷回寄蔡氏昆仲》"] +["冷翠烛,劳光彩。","李贺《苏小小墓》"] +["习习谷风,维山崔嵬。","佚名《小雅·谷风》"] +["微雨后,有桃愁杏怨,红泪淋浪。","秦观《沁园春·宿霭迷空》"] +["诸娣从之,祁祁如云。","佚名《韩奕》"] +["人生几何时,怀忧终年岁。","蔡琰《悲愤诗》"] +["五夜光寒,照来积雪平于栈。","纳兰性德《点绛唇·黄花城早望》"] +["为君愁绝听鸣橹。","杨炎正《蝶恋花·别范南伯》"] +["高堂满地红氍毹,试舞一曲天下无。","岑参《田使君美人舞如莲花北鋋歌》"] +["凭谁去、花衢觅。","柳永《征部乐·雅欢幽会》"] +["妇有长舌,维厉之阶!","佚名《大雅·瞻卬》"] +["一襟幽事,砌蛩能说。","周密《玉京秋·烟水阔》"] +["幽涧愀兮流泉深,善手明徽高张清。","李白《幽涧泉》"] +["我觉秋兴逸,谁云秋兴悲?","李白《秋日鲁郡尧祠亭上宴别杜补阙范侍御》"] +["陆机雄才岂自保?李斯税驾苦不早。","李白《行路难三首》"] +["主闇于上,臣诈于下,灭亡无日","《荀子·君道》"] +["冰,水为之,而寒于水。","荀子《劝学》"] +["小人不激不励,不见利不劝。","《中说·卷一·王道篇》"] +["遥看魏公墓,突兀前山后。","高适《自淇涉黄河途中作十三首》"] +["雄州雾列,俊采星驰。","王勃《滕王阁序》"] +["凡是人 皆须爱 天同覆 地同载","《弟子规·泛爱众》"] +["每忆上方谁请剑,空嗟高庙自藏弓。","高启《吊岳王墓》"] +["乱则国危,治则国安。","《荀子·王霸》"] +["今人犹歌李白诗,明月还如李白时。","唐寅《把酒对月歌》"] +["长安重桃李,徒染六街尘!","于武陵《赠卖松人》"] +["叹西园、已是花深无地,东风何事又恶?","周邦彦《瑞鹤仙·悄郊原带郭》"] +["蚕月条桑,取彼斧斨,以伐远扬,猗彼女桑。","佚名《七月》"] +["柳梢听得黄鹂语,此是春来第一声。","杨载《到京师》"] +["桃花细逐杨花落,黄鸟时兼白鸟飞。","杜甫《曲江对酒》"] +["念上国,谁是、脍鲈江汉未归客。","史达祖《秋霁·江水苍苍》"] +["力排南山三壮士,齐相杀之费二桃。","李白《梁甫吟》"] +["古道连绵走西京,紫阙落日浮云生。","李白《灞陵行送别》"] +["之子于苗,选徒嚣嚣。","佚名《车攻》"] +["块独处此幽默兮,愀空山而愁人。","李白《鸣皋歌送岑徵君》"] +["为上者疑,为下者惧。上下背德,祸必兴焉。","《罗织经·事上卷第二》"] +["枣花至小能成实,桑叶虽柔解吐丝。","王溥《咏牡丹》"] +["郧国稻苗秀,楚人菰米肥。","王维《送友人南归》"] +["可堪翦伐到园陵!","魏禧《登雨花台》"] +["独倚阑干心绪乱。","欧阳修《蝶恋花·画阁归来春又晚》"] +["吴兴才人怨春风,桃花满陌千里红。","李贺《送沈亚之歌·并序》"] +["令必行,禁必止。","《韩非子·饰邪》"] +["乳鸭池塘水浅深,熟梅天气半阴晴。","戴复古《初夏游张园》"] +["世人见我恒殊调,闻余大言皆冷笑。","李白《上李邕》"] +["八风儛遥翮,九野弄清音。","萧道成《群鹤咏》"] +["时不可失,丧不可久","《国语·晋语·里克杀奚齐而秦立惠公》"] +["忆对中秋丹桂丛。花在杯中。月在杯中。","辛弃疾《一剪梅·中秋元月》"] +["远芳侵古道,晴翠接荒城。","白居易《草 / 赋得古原草送别》"] +["吾闻田成子,诈仁犹自王。","皮日休《橡媪叹》"] +["桃花春水渌,水上鸳鸯浴。","韦庄《菩萨蛮》"] +["兰舟同上鸳鸯浦,天气嫩寒轻暖。","张翥《摸鱼儿·送春》"] +["我爱铜官乐,千年未拟还。","李白《铜官山醉后绝句》"] +["毋令管与鲍,千载独知名。","李白《读诸葛武侯传书怀赠长安崔少府叔封昆季》"] +["章台柳,章台柳,往日依依今在否?","韩翃《章台柳·寄柳氏》"] +["自是不归归便得,五湖烟景有谁争。","崔涂《春夕》"] +["意气豪华何处在,空余草露湿罗衣。","孟浩然《高阳池送朱二》"] +["宁做太平犬,莫做离乱人!","《增广贤文·下集》"] +["怅寥廓,问苍茫大地,谁主沉浮?","毛泽东《沁园春·长沙》"] +["蛮腥未洗,海客一怀凄惋。","吴文英《琐窗寒·玉兰》"] +["云鬓花颜金步摇,芙蓉帐暖度春宵。","白居易《长恨歌》"] +["已有空乐好,况闻青猿哀。","李白《寻山僧不遇作》"] +["念去来、岁月如流,徘徊久、叹息愁思盈。","周邦彦《绮寮怨·上马人扶残醉》"] +["若教坐待成林日,滋味还堪养老夫。","柳宗元《柳州城西北隅种柑树》"] +["忆昔西池池上饮,年年多少欢娱。","晁冲之《临江仙·忆昔西池池上饮》"] +["天子好美女,夫妇不成双。","曹邺《捕渔谣》"] +["天涯旧恨,试看几许消魂,长亭门外山重叠。","张元干《石州慢·寒水依痕》"] +["白鹇白如锦,白雪耻容颜。","李白《赠黄山胡公求白鹇》"] +["消得一声莺,东风三月情。","纳兰性德《菩萨蛮·为春憔悴留春住》"] +["曷云其还?政事愈蹙。","佚名《小明》"] +["池上无风有落晖,杨花晴后自飞飞。","韩愈《池上絮》"] +["杜陵有布衣,老大意转拙。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["故国三千里,深宫二十年。","张祜《宫词·故国三千里》"] +["幽陵异域风烟改,亭障连连古今在。","李昂《从军行》"] +["赳赳武夫,公侯好仇。","佚名《兔罝》"] +["曲径穿花寻蛱蝶,虚阑傍日教鹦鹉。","岳珂《满江红·小院深深》"] +["十二曲阑春寂寂,隔蓬山、何处窥人面?","梁启超《金缕曲·丁未五月归国旋复东渡却寄沪上诸君子》"] +["耕夫召募爱楼船,春草青青万项田;","张继《闾门即事》"] +["谩教人、羞去上层楼,平芜碧。","辛弃疾《满江红·暮春》"] +["从今后,梦魂千里,夜夜岳阳楼。","徐君宝妻《满庭芳·汉上繁华》"] +["一朝沟陇出,看取拂云飞。","李贺《马诗二十三首》"] +["独有南山桂花发,飞来飞去袭人裾。","卢照邻《长安古意》"] +["夏桀之常违兮,乃遂焉而逢殃。","屈原《离骚》"] +["青天蜀道难,红叶吴江冷。","张可久《普天乐·秋怀》"] +["颜淫溢而将罢兮,柯仿佛而萎黄。","宋玉《九辩》"] +["往日用钱捉私铸,今许铅锡和青铜。","杜甫《岁晏行》"] +["兰有秀兮菊有芳,怀佳人兮不能忘。","刘彻《秋风辞》"] +["宿妆眉浅粉山横。","温庭筠《遐方怨·花半拆》"] +["拱木不生危,松柏不生埤。","《国语·晋语·医和视平公疾》"] +["坐中客,翠羽帔,紫绮裘。","苏辙《水调歌头·徐州中秋》"] +["清琴弄云月,美酒娱冬春。","李白《陈情赠友人》"] +["江春不肯留行客,草色青青送马蹄。","刘长卿《送李判官之润州行营》"] +["看槛曲萦红,檐牙飞翠。","姜夔《翠楼吟·淳熙丙午冬》"] +["归梦湖边,还迷镜中路。","吴文英《祝英台近·除夜立春》"] +["畟畟良耜,俶载南亩。","佚名《周颂·良耜》"] +["远客投名贤,真堪写怀抱。","李白《于五松山赠南陵常赞府》"] +["十月吴山晓,梅花落敬亭。","李白《观胡人吹笛》"] +["得欢当作乐,斗酒聚比邻。","陶渊明《杂诗·人生无根蒂》"] +["武人东征,不皇他矣。","佚名《渐渐之石》"] +["见素抱朴,少思寡欲,绝学无忧。","《老子·道经·第十九章》"] +["水横洞以下渌,波小声而上闻。","李白《鸣皋歌送岑徵君》"] +["不狩不猎,胡瞻尔庭有县鹑兮?","佚名《伐檀》"] +["别来怅、光阴容易,还又酴醿,牡丹开遍。","卢祖皋《倦寻芳·香泥垒燕》"] +["赏心难久留,离念来相关。","柳宗元《构法华寺西亭》"] +["山河犹带英雄气,试上最高处闲坐地。","张养浩《山坡羊·未央怀古》"] +["廉蔺若未死,孙吴知暗同。","高适《李云南征蛮诗》"] +["一上高城万里愁,蒹葭杨柳似汀洲。","许浑《咸阳城东楼 / 咸阳城西楼晚眺 / 西门》"] +["暮雨相呼失,寒塘欲下迟。","崔涂《孤雁二首·其二》"] +["用仁义以治天下,行礼乐以变海内,公赏罚以定干戈。","《水浒传·第八十二回》"] +["天街小雨润如酥,草色遥看近却无。","韩愈《早春呈水部张十八员外 / 初春小雨 / 早春》"] +["坐感岁时歌慷慨,起看天地色凄凉。","王安石《葛溪驿》"] +["既生既育,比予于毒。","佚名《谷风》"] +["何事非相思,江上葳蕤竹。","吴均《登二妃庙》"] +["有才而性缓,定属大才。有智而气和,斯为大智。","《格言联璧·存养类》"] +["山王今已贵,肯听竹禽啼。","梅尧臣《梦后寄欧阳永叔》"] +["汉廷急诏谁先入,楚路高歌自欲翻。","李商隐《赠刘司户蕡》"] +["相去万余里,故人心尚尔。","佚名《客从远方来》"] +["博学而详说之,将以反说约也。","《孟子·离娄章句下·第十五节》"] +["丈夫相见且为乐,槌牛挝鼓会众宾。","李白《猛虎行》"] +["人强健,清尊素影,长愿相随。","晁端礼《绿头鸭·咏月》"] +["朝搴阰之木兰兮,夕揽洲之宿莽。","屈原《离骚》"] +["华表月明归夜鹤,叹当时、花竹今如此。","吴文英《金缕歌·陪履斋先生沧浪看梅》"] +["敷时绎思,我徂维求定。","佚名《周颂·赉》"] +["水涵空、阑干高处,送乱鸦斜日落渔汀。","吴文英《八声甘州·灵岩陪庾幕诸公游》"] +["怨调慢声如欲语,一曲未终日移午。","岑参《秦筝歌送外甥萧正归京》"] +["蟾蜍蚀圆影,大明夜已残。","李白《古朗月行》"] +["列骑低残月,鸣茄度碧虚。","柳宗元《韦使君黄溪祈雨见召从行至祠下口号》"] +["几度因风残絮,照花斜阳。","史达祖《寿楼春·寻春服感念》"] +["寂寞尊前席上,唯愁海角天涯。","李清照《转调满庭芳·芳草池塘》"] +["善游者溺,善骑者堕,各以所好反自为祸。","《文子·卷四·符言》"] +["多君骋逸藻,掩映当时人。","李白《陈情赠友人》"] +["小园几许,收尽春光。","秦观《行香子·树绕村庄》"] +["而今剩了,低迷鱼艇,模粘雁字。","邓廷桢《水龙吟·雪中登大观亭》"] +["告于文人,锡山土田。","佚名《大雅·江汉》"] +["苍苍远树围荆门,历历行舟泛巴水。","李白《观元丹丘坐巫山屏风》"] +["举眼风光长寂寞,满朝官职独蹉跎。","白居易《醉赠刘二十八使君》"] +["天下之百姓皆上同于天子,而不上同于天,则灾犹未去也。","《墨子·11章 尚同(上)》"] +["一涓春水点黄昏,便没顿、相思处。","史达祖《留春令·咏梅花》"] +["薄污我私,薄浣我衣。","佚名《葛覃》"] +["困倚危楼,过尽飞鸿字字愁。","秦观《减字木兰花·天涯旧恨》"] +["还须黑头取方伯,莫谩白首为儒生。","李白《悲歌行》"] +["白头居士无呵殿,只有乘肩小女随。","姜夔《鹧鸪天·正月十一日观灯》"] +["汉祖吕氏,食其在傍。","李白《雪谗诗赠友人》"] +["平生所娇儿,颜色白胜雪。","杜甫《北征》"] +["相顾无言,惟有泪千行。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["韩侯受命,王亲命之:缵戎祖考,无废朕命。","佚名《韩奕》"] +["我妓今朝如花月,他妓古坟荒草寒。","李白《东山吟》"] +["栽培剪伐须勤力,花易凋零草易生。","苏舜钦《题花山寺壁》"] +["夜寒惊被薄,泪与灯花落。","纳兰性德《菩萨蛮·萧萧几叶风兼雨》"] +["贪华风雨中,眒忽数百适。","左思《娇女诗》"] +["善作者,不必善成;善始者,不必善终。","《战国策·燕二·昌国君乐毅为燕昭王合五国之兵而攻齐》"] +["君子之心事,天青日白,不可使人不知","《菜根谭·概论》"] +["车声上路合,柳色东城翠。","孟浩然《清明即事》"] +["渡头杨柳青青。枝枝叶叶离情。","晏几道《清平乐·留人不住》"] +["缚虎手,悬河口,车如鸡栖马如狗。","贺铸《行路难·缚虎手》"] +["六翮飘飖私自怜,一离京洛十余年。","高适《别董大二首》"] +["释子吾家宝,神清慧有馀。","钱起《送外甥怀素上人归乡侍奉》"] +["得其所白,不可谓无白;得其所坚,不可谓无坚。","《公孙龙子·坚白论》"] +["置之勿复道,且寄须臾闲。","柳宗元《构法华寺西亭》"] +["山中一夜雨,树杪百重泉。","王维《送梓州李使君》"] +["丝衣其紑,载弁俅俅。","佚名《周颂·丝衣》"] +["还要倩、流水高山,花前慢奏。","顾太清《玉烛新·白海棠》"] +["周公称大圣,管蔡宁相容。","李白《箜篌谣》"] +["兵强胜人,人强胜天","《逸周书·卷三·文传解》"] +["人异而心异,择其弱者以攻之,其神必溃。","《罗织经·问罪卷第十》"] +["奢侈足以败家;悭吝亦足以败家。","《围炉夜话·第五九则》"] +["已觉秋窗秋不尽,那堪风雨助凄凉!","曹雪芹《代别离·秋窗风雨夕》"] +["主人酒尽君未醉,薄暮途遥归不归。","高适《送李少府时在客舍作》"] +["早征非是官人恶,去岁官家事戎索。","元稹《织妇词》"] +["言者所以在意,得意而忘言。","《庄子·杂篇·外物》"] +["误吞丹药移真骨,偷下瑶池脱旧胎。","曹雪芹《咏红梅花得“梅”字》"] +["吾庐小,在龙蛇影外,风雨声中。","辛弃疾《沁园春·灵山斋庵赋时筑偃湖未成》"] +["胜地不常,盛筵难再;兰亭已矣,梓泽丘墟。","王勃《滕王阁序》"] +["葛屦五两,冠緌双止。","佚名《南山》"] +["三千年后知谁在?何必劳君报太平!","罗隐《黄河》"] +["游人五陵去,宝剑值千金。","孟浩然《送朱大入秦》"] +["居士先生老矣,真梦里、相对残釭。","苏轼《满庭芳·三十三年》"] +["于福作罪,其罪非轻;于苦作福,其福最大。","《格言联璧·悖凶类》"] +["积水不可极,安知沧海东。","王维《送秘书晁监还日本国》"] +["对绿蚁翠蛾,怎忍轻舍。","柳永《抛球乐·晓来天气浓淡》"] +["夫浅知之所争者,末矣。","《列子·说符》"] +["纵然偷期暗会,长是匆匆。","柳永《集贤宾·小楼深巷狂游遍》"] +["千里渥洼种,名动帝王家。","辛弃疾《水调歌头·寿赵漕介庵》"] +["莫学武陵人,暂游桃源里。","裴迪《崔九欲往南山马上口号与别 / 留别王维 / 送崔九》"] +["因知海上神仙窟,只似人间富贵家。","韦庄《陪金陵府相中堂夜宴》"] +["行尽潇湘到洞庭。","张孝祥《浣溪沙·洞庭》"] +["麻衣黑肥冲北风,带酒日晚歌田中。","李贺《野歌》"] +["一年三百六十日,多是横戈马上行。","戚继光《马上作》"] +["节物岂不好,秋怀何黯然!","欧阳修《秋怀》"] +["名编壮士籍,不得中顾私。","曹植《白马篇》"] +["总是别时情,那待分明语。","纳兰性德《生查子·惆怅彩云飞》"] +["胡人落泪沾边草,汉使断肠对归客。","李颀《听董大弹胡笳声兼寄语弄房给事》"] +["黯与山僧别,低头礼白云。","李白《秋浦歌十七首》"] +["自恨志不遂,泣涕如涌泉。","徐干《室思》"] +["章台柳,章台柳,往日依依今在否?","韩翃《章台柳·寄柳氏》"] +["马者,所以命形也;白者,所以命色也。","《公孙龙子·白马论》"] +["空室自生风,百鸟翩南征。","曹植《杂诗七首》"] +["卧龙跃马终黄土,人事依依漫寂寥。","杜甫《阁夜》"] +["岂不尔受?既其女迁。","佚名《巷伯》"] +["千回来绕百回看,蜂作婢,莺为使。","苏轼《天仙子·走马探花花发未》"] +["内宫传诏问戎机,载笔金銮夜始归。","李德裕《长安秋夜》"] +["幸有意中人,堪寻访。","柳永《鹤冲天·黄金榜上》"] +["流星白羽腰间插,剑花秋莲光出匣。","李白《胡无人》"] +["额名旧载零陵志,碑字新镌子厚诗。","陶弼《南池》"] +["平生太湖上,短棹几经过。","佚名《水调歌头·平生太湖上》"] +["青眼高歌俱未老,向尊前、拭尽英雄泪。","纳兰性德《金缕曲·赠梁汾》"] +["恨分破、教孤令。","黄庭坚《品令·茶词》"] +["殷商之旅,其会如林。","佚名《大明》"] +["书痴者文必工,艺痴者技必良。","《聊斋志异·卷二·阿宝》"] +["闵奇思之不通兮,将去君而高翔。","宋玉《九辩》"] +["酒浇胸次之磊块,菊制短世之颓龄。","黄庭坚《送王郎》"] +["观此遗物虑,一悟得所遣。","谢灵运《从斤竹涧越岭溪行》"] +["算风流未减,年年醉里,把花枝问。","辛弃疾《水龙吟·载学士院有之》"] +["故古之能致功名者,众人助之以力,近者结之以成,远者誉之以名,尊者载之以势。","《韩非子·功名》"] +["怨声坐使旧声阑,俗耳只知繁手,不须弹。","苏轼《虞美人·琵琶》"] +["亲爱我 孝何难 亲憎我 孝方贤","《弟子规·入则孝》"] +["闲坐悲君亦自悲,百年都是几多时。","元稹《遣悲怀三首·其三》"] +["买花载酒长安市,又争似、家山见桃李。","欧阳修《青玉案·一年春事都来几》"] +["犹有遗簪,不展秋心,能卷几多炎热。","张炎《疏影·咏荷叶》"] +["洗心得真情,洗耳徒买名。","李白《送裴十八图南归嵩山二首》"] +["蝴蝶不随春去,入薰风池阁。","蒋元龙《好事近·叶暗乳鸦啼》"] +["摧烧之,当风扬其灰!","佚名《有所思》"] +["独敲初夜磬,闲倚一枝藤。","李商隐《北青萝》"] +["岂弟君子,民之攸塈。","佚名《泂酌》"] +["征起适遇风云会,扶颠始知筹策良。","杜甫《洗兵马》"] +["岂余身之殚殃兮,恐皇舆之败绩!","屈原《离骚》"] +["为谁频断续,相和砧杵?","姜夔《齐天乐·蟋蟀》"] +["举头红日近,回首白云低。","寇准《咏华山》"] +["春风知别苦,不遣柳条青。","李白《劳劳亭》"] +["行云去后遥山暝,已放笙歌池院静。","张先《木兰花·乙卯吴兴寒食》"] +["可惜风流总闲却!","王安石《千秋岁引·秋景》"] +["隰有苌楚,猗傩其华,夭之沃沃。","佚名《隰有苌楚》"] +["胡能有定?报我不述。","佚名《日月》"] +["柳重烟深,雪絮飞来往。","欧阳修《蝶恋花·面旋落花风荡漾》"] +["花色融,人竟赏,尽是绣鞍朱鞅。","薛昭蕴《喜迁莺·清明节》"] +["晚节觉此疏,猎精草太玄。","李白《留别广陵诸公 / 留别邯郸故人》"] +["绣帘才卷,一楼空翠回薄。","徐灿《念奴娇·西湖雨感次素庵韵》"] +["潜心默祷若有应,岂非正直能感通!","韩愈《谒衡岳庙遂宿岳寺题门楼》"] +["城上青山如屋里,东家流水入西邻。","王维《春日与裴迪过新昌里访吕逸人不遇》"] +["千虚不如一实。","《警世通言·卷三》"] +["一能胜予,君子不可无此小心。吾何畏彼,丈夫不可无此大志。","《格言联璧·持躬类》"] +["纸上得来终觉浅,绝知此事要躬行。","陆游《冬夜读书示子聿》"] +["春雪满空来,触处似花开。","赵嘏《喜张沨及第》"] +["箫鼓哀吟感鬼神,宾从杂遝实要津。","杜甫《丽人行》"] +["自我来黄州,已过三寒食。","苏轼《寒食雨二首》"] +["既见君子,我心写兮。","佚名《蓼萧》"] +["欲上治安双阙远,空怅望,过维扬。","李好古《江城子·平沙浅草接天长》"] +["自许封侯在万里。","陆游《夜游宫·记梦寄师伯浑》"] +["炎帝垂灵编,言此殊足珍。","柳宗元《种白蘘荷》"] +["暝色入高楼,有人楼上愁。","李白《菩萨蛮·平林漠漠烟如织》"] +["几遍青山酬对好,依旧黛眉当阁。","徐灿《念奴娇·西湖雨感次素庵韵》"] +["是时君王在镐京,五云垂晖耀紫清。","李白《侍从宜春苑奉诏赋龙池柳色初青听新莺百啭歌》"] +["孤忠耿耿,剑铓冷浸秋月。","黄中辅《念奴娇·炎精中否》"] +["白云天竺去来,图画里、峥嵘楼观开。","刘过《沁园春·斗酒彘肩》"] +["东风里,朱门映柳,低按小秦筝。","秦观《满庭芳·晓色云开》"] +["柏台霜威寒逼人,热海炎气为之薄。","岑参《热海行送崔侍御还京》"] +["弦歌咏唐尧,脱落隐簪组。","李白《赠清漳明府侄聿》"] +["饥渴辛勤两翅垂,独下寒汀立。","朱敦儒《卜算子·旅雁向南飞》"] +["允文允武,昭假烈祖。","佚名《鲁颂·泮水》"] +["狗吠深巷中,鸡鸣桑树颠。","陶渊明《归园田居·其一》"] +["莫唱阳关,风流公子方终宴。","苏轼《点绛唇·再和送钱公永》"] +["野色浩无主,秋明空旷间。","李贺《送韦仁实兄弟入关》"] +["华鬓星星,惊壮志成虚,此身如寄。","陆游《双头莲·呈范至能待制》"] +["传之七十有二代,以为封禅玉检明堂基。","李商隐《韩碑》"] +["旅力方刚,经营四方。","佚名《北山》"] +["凤衔金榜出云来,平地一声雷。","韦庄《喜迁莺·街鼓动》"] +["习习谷风,维风及雨。","佚名《小雅·谷风》"] +["沧海桑田,谓世事之多变;河清海晏,兆天下之升平。","《幼学琼林·卷一·地舆》"] +["簌簌衣巾落枣花,村南村北响缫车,牛衣古柳卖黄瓜。","苏轼《浣溪沙·簌簌衣巾落枣花》"] +["原尝春陵六国时,开心写意君所知。","李白《扶风豪士歌》"] +["凄凄去亲爱,泛泛入烟雾。","韦应物《初发扬子寄元大校书》"] +["一灯群动息,孤磬四天空。","厉鹗《灵隐寺月夜》"] +["至七月,与白马王还国。","曹植《赠白马王彪·并序》"] +["长沟流月去无声。","陈与义《临江仙·夜登小阁忆洛中旧游》"] +["日暖桑麻光似泼,风来蒿艾气如薰。","苏轼《浣溪沙·软草平莎过雨新》"] +["勿恃势力而凌逼孤寡,毋贪口腹而恣杀生禽。","《朱子家训·全文》"] +["女子有行,远兄弟父母。","佚名《蝃蝀》"] +["血染红笺,泪题锦句。西湖岂忆相思苦。","刘将孙《踏莎行·闲游》"] +["教说与春来要,寻花伴侣。","姜夔《玲珑四犯·越中岁暮闻箫鼓感怀》"] +["绿阴春尽,飞絮绕香阁。","晏几道《六幺令·绿阴春尽》"] +["闲情兼嘿语,携杖赴岩泉。","王勃《春日还郊》"] +["长安大道连狭斜,青牛白马七香车。","卢照邻《长安古意》"] +["天下从事者,不可以无法仪,无法仪而其事能成者,无有也。","《墨子·04章 法仪》"] +["亢龙宾天群龙战,潜龙跃出飞龙现。","成鹫《仙城寒食歌·绍武陵》"] +["巾欹更觉霜侵鬓,语妙何妨石作肠。","陈师道《次韵李节推九日登南山》"] +["庙堂无计可平戎,坐使甘泉照夕峰。","陈与义《伤春》"] +["君不见少壮从军去,白首流离不得还。","鲍照《拟行路难十八首》"] +["几回天上葬神仙,漏声相将无断绝。","李贺《官街鼓》"] +["樽酒聊可酌,放歌谅徒为。","柳宗元《零陵赠李卿元侍御简吴武陵》"] +["今年花胜去年红。","欧阳修《浪淘沙·把酒祝东风》"] +["谋之不臧,则具是依。","佚名《小旻》"] +["松生数寸时,遂为草所没。","吴均《赠王桂阳》"] +["且放白鹿青崖间,须行即骑访名山。","李白《梦游天姥吟留别 / 梦游天姥山别东鲁诸公》"] +["圣人终不为大,故能成其大。","《老子·德经·第六十三章》"] +["东门之杨,其叶肺肺。","佚名《东门之杨》"] +["狐狸驰赴穴,飞鸟翔故林。","王粲《七哀诗三首·其二》"] +["行役在战场,相见未有期。","苏武《留别妻》"] +["晚来天欲雪,能饮一杯无?","白居易《问刘十九》"] +["利人乎即为,不利人乎即止。","《墨子·32章 非乐(上)》"] +["还持鹿皮几,日暮隐蓬蒿。","王维《春园即事》"] +["我今又复罹此旱,凛凛疲民在沟渎。","苏轼《次韵舒尧文祈雪雾猪泉》"] +["好言自口,莠言自口。","佚名《正月》"] +["东郡趋庭日,南楼纵目初。","杜甫《登兖州城楼》"] +["神之斿,过天门,车千乘,敦昆仑。","刘彻《华晔晔》"] +["百舌问花花不语,低回似恨横塘雨。","温庭筠《惜春词》"] +["泉眼无声惜细流,树阴照水爱晴柔。","杨万里《小池》"] +["豁开青冥颠,泻出万丈泉。","施肩吾《瀑布》"] +["露侵宿酒,疏帘淡月,照人无寐。","张辑《疏帘淡月·寓桂枝香秋思》"] +["鹤爱孤松云爱山,宦情微禄免相关。","汪遵《彭泽》"] +["昔为匣中玉,今为粪上英。","石崇《王明君》"] +["应为洛神波上袜,至今莲蕊有香尘。","温庭筠《莲花》"] +["妇无公事,休其蚕织。","佚名《大雅·瞻卬》"] +["或有骨肉俱,欲言不敢语。","蔡琰《悲愤诗》"] +["急应河阳役,犹得备晨炊。","杜甫《石壕吏》"] +["细草愁烟,幽花怯露。","晏殊《踏莎行·细草愁烟》"] +["日月照之何不及此?惟有北风号怒天上来。","李白《北风行》"] +["解放胡鹰逐塞鸟,能将代马猎秋田。","崔颢《雁门胡人歌》"] +["画船捶鼓催君去。高楼把酒留君住。","舒亶《菩萨蛮·画船捶鼓催君去》"] +["澹然离言说,悟悦心自足。","柳宗元《晨诣超师院读禅经》"] +["越女红裙湿,燕姬翠黛愁。","杜甫《陪诸贵公子丈八沟携妓纳凉晚际遇雨二首》"] +["烛花摇影,冷透疏衾刚欲醒。","纳兰性德《减字木兰花·烛花摇影》"] +["南登碣石馆,遥望黄金台。","陈子昂《燕昭王》"] +["风萧萧而异响,云漫漫而奇色。","江淹《别赋》"] +["绿树村边合,青山郭外斜。","孟浩然《过故人庄》"] +["好去不须频下泪,老僧相伴有烟霞。","金地藏《送童子下山》"] +["角声吹彻小梅花。夜长人忆家。","赵长卿《阮郎归·客中见梅》"] +["天街曾醉美人畔,凉枝移插乌巾。","吴文英《夜飞鹊·蔡司户席上南花》"] +["相携话别郑原上,共道长途怕雪泥。","苏辙《怀渑池寄子瞻兄》"] +["诗人助兴常思玩,野客添愁不忍观。","曹雪芹《香菱咏月·其一》"] +["相逢清露下,流影湿衣襟。","袁枚《夜过借园见主人坐月下吹笛》"] +["罗绶分香,翠绡对泪,几多幽怨。","陈亮《水龙吟·春恨》"] +["尽日无人帘幕挂,更风递、游丝时过墙。","秦观《沁园春·宿霭迷空》"] +["乃知兵者是凶器,圣人不得已而用之。","李白《战城南》"] +["音尘远,楚天危楼独倚。","陆游《月上海棠·斜阳废苑朱门闭》"] +["折梅花去也,城西炬火,照琼瑶碎。","邓廷桢《水龙吟·雪中登大观亭》"] +["二三载,如鱼似水相知。","柳永《驻马听·凤枕鸾帷》"] +["英英白云,露彼菅茅。","佚名《白华》"] +["重来故人不见,但依然、杨柳小楼东。","戴复古《木兰花慢·莺啼啼不尽》"] +["蝉鸣空桑林,八月萧关道。","王昌龄《塞下曲四首》"] +["由来万夫勇,挟此生雄风。","李白《结客少年场行》"] +["浮客空留听,褒城闻曙鸡。","沈佺期《夜宿七盘岭》"] +["伴人无寐,秦淮应是孤月。","邓剡《酹江月·驿中言别》"] +["但觉秋来归梦好,西南自有长城。","苏轼《河满子·湖州作寄益守冯当世》"] +["三春白雪归青冢,万里黄河绕黑山。","柳中庸《征人怨 / 征怨》"] +["点滴芭蕉心欲碎,声声催忆当初。","纳兰性德《临江仙·点滴芭蕉心欲碎》"] +["林下路,水边石。","吴文英《瑞鹤仙·秋感》"] +["精诚由中,故其文语感动人深。","《论衡·卷十三·超奇篇》"] +["众夫希所见,天老教轩皇。","张衡《同声歌》"] +["扁舟泛湖海,长揖谢公卿。","孟浩然《自洛之越》"] +["洲岛骤回合,圻岸屡崩奔。","谢灵运《入彭蠡湖口》"] +["随分杯盘,等闲歌舞。","辛弃疾《踏莎行·庚戌中秋后二夕带湖篆冈小酌》"] +["实颖实栗,即有邰家室。","佚名《大雅·生民》"] +["琵琶可是不堪听。","张辑《南歌子·柳户朝云湿》"] +["子胥既弃吴江上,屈原终投湘水滨。","李白《行路难三首》"] +["浦边清水明素足,别有浣沙吴女郎。","李白《和卢侍御通塘曲》"] +["长安一相见,呼我谪仙人。","李白《对酒忆贺监二首》"] +["白帝晓猿断,黄牛过客迟。","李白《窜夜郎于乌江留别宗十六璟》"] +["道之云远,曷云能来?","佚名《雄雉》"] +["画虎画皮难画骨,知人知面不知心。","《增广贤文·上集》"] +["自是寻春去校迟,不须惆怅怨芳时。","杜牧《叹花 / 怅诗》"] +["古调虽自爱,今人多不弹。","刘长卿《听弹琴》"] +["敛襟独闲谣,缅焉起深情。","陶渊明《九日闲居》"] +["岂无饮酒?不如叔也。","佚名《叔于田》"] +["君子万年,保其家邦。","佚名《瞻彼洛矣》"] +["老母与子别,呼天野草间。","李白《豫章行》"] +["官街十字改丁字,钉破并州渠亦亡。","元好问《过晋阳故城书事》"] +["回风动地起,秋草萋已绿。","佚名《东城高且长》"] +["薜萝山径入,荷芰水亭开。","杜审言《夏日过郑七山斋》"] +["禁火天、已是试新妆,岁华到、三分佳处。","万俟咏《三台·清明应制》"] +["故人何不返,春华复应晚。","柳恽《江南曲》"] +["将仲子兮,无逾我墙,无折我树桑。","佚名《将仲子》"] +["君马黄,我马白。","李白《君马黄》"] +["四时从经,万姓允诚。","佚名《卿云歌》"] +["缬被初寒,宿酒全醒,搅碎乱蛩双杵。","纳兰性德《疏影·芭蕉》"] +["燕燕于飞,下上其音。","佚名《燕燕》"] +["才大者 望自大 人所服 非言大","《弟子规·泛爱众》"] +["有所思,乃在大海南。","佚名《有所思》"] +["忙日苦多闲日少,新愁常续旧愁生。","陆游《浣溪沙·和无咎韵》"] +["岸远沙平,日斜归路晚霞明。","欧阳炯《南乡子·岸远沙平》"] +["秋雨,秋雨,一半因风吹去。","纳兰性德《如梦令·黄叶青苔归路》"] +["爱人利人者,天必福之;恶人贼人者,天必祸之。","《墨子·04章 法仪》"] +["升彼虚矣,以望楚矣。","佚名《鄘风·定之方中》"] +["同是人 类不齐 流俗众 仁者希","《弟子规·亲仁》"] +["石门流水遍桃花,我亦曾到秦人家。","李白《下途归石门旧居》"] +["维彼愚人,覆狂以喜。","佚名《桑柔》"] +["苏小慵多兰渚闲,融融浦日鵁鶄寐。","温庭筠《春洲曲》"] +["迎新在此岁,唯恐踵前迹。","柳宗元《田家三首》"] +["翠尊易泣,红萼无言耿相忆。","姜夔《暗香·旧时月色》"] +["此路无知己,明珠莫暗投。","高适《送魏八》"] +["日月依辰至,举俗爱其名。","陶渊明《九日闲居》"] +["竹树夹流水,孤城对远山。","高适《自淇涉黄河途中作十三首》"] +["春情只到梨花薄,片片催零落。","纳兰性德《虞美人·春情只到梨花薄》"] +["花是去年红,吹开一夜风。","王安石《菩萨蛮·数间茅屋闲临水》"] +["天地生君子,君子理天地。","《荀子·王制》"] +["奴面不如花面好。","李清照《减字木兰花·卖花担上》"] +["怅平生、交游零落,只今馀几!","辛弃疾《贺新郎·甚矣吾衰矣》"] +["人命至重,有贵千金","《千金方·序·序》"] +["夕阳下,酒旆闲,两三航未曾着岸。","马致远《寿阳曲·远浦帆归》"] +["儿辈功名都付与,长日惟消棋局。","辛弃疾《念奴娇·登建康赏心亭呈史致道留守》"] +["知君却是为情秾。","苏轼《西江月·再用前韵戏曹子方》"] +["朔风吹雪透刀瘢,饮马长城窟更寒。","卢汝弼《和李秀才边庭四时怨·其四》"] +["输与五陵公子,此时梦绕花前。","纳兰性德《清平乐·发汉儿村题壁》"] +["风雨送人来,风雨留人住。","游次公《卜算子·风雨送人来》"] +["欲出鸿都门,阴云蔽城阙。","温庭筠《侠客行》"] +["绿桑高下映平川,赛罢田神笑语喧。","欧阳修《田家》"] +["日暖桑麻光似泼,风来蒿艾气如薰。","苏轼《浣溪沙·软草平莎过雨新》"] +["归棹洛阳人,残钟广陵树。","韦应物《初发扬子寄元大校书》"] +["先知风起月含晕,尚自露寒花未开。","李商隐《正月崇让宅》"] +["圣贤留余迹,事事在中都。","陶渊明《赠羊长史·并序》"] +["人之无良,我以为君!","佚名《鹑之奔奔》"] +["谁遣虞卿裁道帔,轻绡一匹染朝霞。","李贺《南园十三首》"] +["以御宾客,且以酌醴。","佚名《吉日》"] +["北方有佳人,绝世而独立。","李延年《李延年歌》"] +["曾伴浮云归晚翠,犹陪落日泛秋声。","高蟾《金陵晚望》"] +["与君落花院,台上起双鬟。","王勃《落花落》"] +["姊妹弟兄皆列土,可怜光彩生门户。","白居易《长恨歌》"] +["四国有王,郇伯劳之。","佚名《下泉》"] +["天际两蛾凝黛,愁与恨,几时极!","韩元吉《霜天晓角·题采石蛾眉亭》"] +["要欲闻清佩,方将出户迎。","王维《待储光羲不至》"] +["渐暗竹敲凉,疏萤照晚,两地魂消。","周邦彦《忆旧游·记愁横浅黛》"] +["夹水苍山路向东,东南山豁大河通。","韦应物《自巩洛舟行入黄河即事寄府县僚友》"] +["何日向,江村躲;何日上,江楼卧。","郑燮《满江红·思家》"] +["范增一去无谋主,韩信原来是逐臣。","严遂成《乌江项王庙》"] +["判教狼藉醉清樽,为问世间醒眼是何人。","纳兰性德《虞美人·残灯风灭炉烟冷》"] +["举头望云林,愧听慧鸟语。","王冕《墨萱图·其一》"] +["地犹鄹氏邑,宅即鲁王宫。","李隆基《经邹鲁祭孔子而叹之》"] +["漫夸鹙子真罗汉,不会牛车是上乘。","李商隐《题白石莲花寄楚公》"] +["资此永幽栖,岂伊年岁别。","谢灵运《邻里相送至方山》"] +["江南春尽离肠断,苹满汀洲人未归。","寇准《江南春·波渺渺》"] +["高城眺落日,极浦映苍山。","王维《登河北城楼作》"] +["必有容,德乃大;必有忍,事乃济。","《格言联璧·处事类》"] +["更好故溪飞雪,小窗深闭。","王沂孙《天香·咏龙涎香》"] +["临分出苦语,愿子书之笏。","苏轼《送欧阳推官赴华州监酒》"] +["湘妃起舞一笑,抚瑟奏清商。","张孝祥《水调歌头·泛湘江》"] +["璧月琼枝空夜夜,菊花人貌自年年。","苏轼《浣溪沙·菊节》"] +["草木岁月晚,关河霜雪清。","杜甫《送远》"] +["四方攸同,皇王维辟。","佚名《文王有声》"] +["天台四万八千丈,对此欲倒东南倾。","李白《梦游天姥吟留别 / 梦游天姥山别东鲁诸公》"] +["漠漠水田飞白鹭,阴阴夏木啭黄鹂。","王维《积雨辋川庄作 / 秋归辋川庄作》"] +["欲寄彩笺兼尺素,山长水阔知何处?","晏殊《蝶恋花·槛菊愁烟兰泣露》"] +["谢公扶病,羊昙挥涕,一醉都休。","元好问《人月圆·玄都观里桃千树》"] +["风情渐老见春羞,到处消魂感旧游。","李煜《赐宫人庆奴》"] +["圣人之道,宽而栗,严而温,柔而直,猛而仁。","《淮南子·汜论训》"] +["舒窈纠兮,劳心悄兮。","佚名《月出》"] +["独有凤凰池上客,阳春一曲和皆难。","岑参《奉和中书舍人贾至早朝大明宫》"] +["如贫得宝,如暗得灯,如饥得食,如旱得云。","《警世通言·卷九》"] +["落梅庭榭香,芳草池塘绿。","晏几道《生查子·落梅庭榭香》"] +["送行无酒亦无钱,劝尔一杯菩萨泉。","苏轼《武昌酌菩萨泉送王子立》"] +["善学者,假人之长以补其短。","《吕氏春秋·纪·孟夏纪》"] +["大风起兮云飞扬。威加海内兮归故乡。","刘邦《大风歌》"] +["花烛夜堂还共语,桂枝秋殿听跻攀。","王守仁《诸门人送至龙里道中二首》"] +["王配于京,世德作求。","佚名《下武》"] +["徘徊丘垄间,依依昔人居。","陶渊明《归园田居·其四》"] +["被之僮僮,夙夜在公。","佚名《采蘩》"] +["山有苞棣,隰有树檖。","佚名《晨风》"] +["书诏许传宫烛,轻罗初试朝衫。","虞集《风入松·寄柯敬仲》"] +["楼外绿阴深,凭栏指点偏东。","顾太清《高山流水·次夫子清风阁落成韵》"] +["谣颂若采之,此言当可取。","元结《农臣怨》"] +["公从何处得纸本,毫发尽备无差讹。","韩愈《石鼓歌》"] +["河海不择细流,故能就其深;","李斯《谏逐客书》"] +["卧看满天云不动,不知云与我俱东。","陈与义《襄邑道中》"] +["风涛回首空三岛,尘壤从头数九垓。","林则徐《赴戍登程口占示家人二首》"] +["乌鹊倦栖,鱼龙惊起,星斗挂垂杨。","陈亮《一丛花·溪堂玩月作》"] +["山口月欲出,先照关城楼。","岑参《初过陇山途中呈宇文判官》"] +["昨夜西风吹折千林梢,渡口小艇滚入沙滩坳。","明本《九字梅花咏》"] +["凡与敌战,若敌人势实,我当严兵以备之,则敌人必不轻动。","《百战奇略·第五卷·实战》"] +["拥怀累代下,言尽意不舒。","陶渊明《赠羊长史·并序》"] +["百年长扰扰,万事悉悠悠。","王绩《赠程处士》"] +["谁知万里客,怀古正踌蹰。","陈子昂《岘山怀古》"] +["梧桐叶上,点点露珠零。","尹鹗《临江仙·深秋寒夜银河静》"] +["古道饶蒺藜,萦回古城曲。","柳宗元《田家三首》"] +["台榭空蒙烟柳暗,白鸟衔鱼欲舞。","纳兰性德《摸鱼儿·午日雨眺》"] +["早知恁么。悔当初、不把雕鞍锁。","柳永《定风波·自春来》"] +["云团广佑尽愁城,未必心肝铁打成。","释智朋《送焦山淇上人见广佑》"] +["经营四方,告成于王。","佚名《大雅·江汉》"] +["天下兵常斗,江东客未还。","杜甫《有叹》"] +["小奴缚鸡向市卖,鸡被缚急相喧争。","杜甫《缚鸡行》"] +["黄河捧土尚可塞,北风雨雪恨难裁。","李白《北风行》"] +["万丈红泉落,迢迢半紫氛。","张九龄《湖口望庐山瀑布泉 / 湖口望庐山瀑布水》"] +["静若萤光,动若流水,尖巧而喜淫","《冰鉴·神骨》"] +["车遥遥兮马洋洋,追思君兮不可忘。","傅玄《车遥遥篇》"] +["冠盖使,纷驰骛,若为情。","张孝祥《六州歌头·长淮望断》"] +["满山残雪对虚堂,想似当年辋口庄。","朱熹《次韵雪后书事二首》"] +["去鲁心犹在,从周力未能。","柳宗元《送元暠师诗》"] +["空名束壮士,薄俗弃高贤。","李白《留别广陵诸公 / 留别邯郸故人》"] +["交流四水抱城斜,散作千溪遍万家。","阮元《吴兴杂诗》"] +["天气困人梳洗懒,眉尖,淡画春山不喜添。","孙道绚《南乡子·春闺》"] +["小臣鲁钝无所能,朝廷记识蒙禄秩。","杜甫《忆昔二首》"] +["秋浦田舍翁,采鱼水中宿。","李白《秋浦歌十七首》"] +["天涯旧恨,独自凄凉人不问。","秦观《减字木兰花·天涯旧恨》"] +["玄鹤降浮云,鱏鱼跃中河。","张华《轻薄篇》"] +["赋料扬雄敌,诗看子建亲。","杜甫《奉赠韦左丞丈二十二韵》"] +["世上功名,老来风味,春归时候。","晁补之《水龙吟·次歆林圣予惜春》"] +["三杯吐然诺,五岳倒为轻。","李白《侠客行》"] +["罗襦不复施,对君洗红妆。","杜甫《新婚别》"] +["汉宫侍女暗垂泪,沙上行人却回首。","王安石《明妃曲二首》"] +["时时引领望天末,何处青山是越中。","孟浩然《渡浙江问舟中人》"] +["大雪满天地,胡为仗剑游?","郑板桥《题游侠图》"] +["虎啸龙吟光世界,太平一统乐如何!","洪秀全《吟剑》"] +["其事虽不立,千载为伤心。","陈子昂《蓟丘览古赠卢居士藏用七首》"] +["入门见孤儿,啼索其母抱。","佚名《妇病行》"] +["抑志而弭节兮,神高驰之邈邈。","屈原《离骚》"] +["应是瑶台伴侣,只多了、毡裘夫妇。","纳兰性德《水龙吟·题文姬图》"] +["回桡楚江滨,挥策扬子津。","李白《送王屋山人魏万还王屋·并序》"] +["出门一笑莫心哀,浩荡襟怀到处开。","林则徐《赴戍登程口占示家人二首》"] +["从今后花底朝朝醉,人间事事忘。","康海《雁儿落带得胜令·饮中闲咏》"] +["翠袖争浮大白,皂罗半插斜红。","苏轼《西江月·坐客见和复次韵》"] +["陌上谁家年少足风流?","韦庄《思帝乡·春日游》"] +["羌灵魂之欲归兮,何须臾而忘反。","屈原《哀郢》"] +["数树海棠红欲尽,争忍,玉闺深掩过年华。","欧阳炯《定风波·暖日闲窗映碧纱》"] +["小桃枝下试罗裳,蝶粉斗遗香。","陈子龙《诉衷情·春游》"] +["君王若道能倾国,玉辇何由过马嵬。","李商隐《马嵬二首》"] +["出其东门,有女如云。","佚名《出其东门》"] +["有渰萋萋,兴雨祈祈。","佚名《大田》"] +["野哭几家闻战伐,夷歌数处起渔樵。","杜甫《阁夜》"] +["人生若浮寄,年时忽蹉跎。","张华《轻薄篇》"] +["宽柔以教,不报无道,南方之强也。君子居之。","《中庸·第十章》"] +["有艳淑女在闺房,室迩人遐毒我肠。","司马相如《凤求凰》"] +["方苞方体,维叶泥泥。","佚名《行苇》"] +["一夜翦刀收玉蕊。","苏轼《天仙子·走马探花花发未》"] +["十子若不肖,不如一女英。","李白《东海有勇妇》"] +["北风卷地白草折,胡天八月即飞雪。","岑参《白雪歌送武判官归京》"] +["金印辉煌未入手,白发种种来无情。","陆游《长歌行》"] +["出时山眼白,高后海心明。","李白《雨后望月》"] +["夜栖寒月静,朝步落花闲。","李白《赠黄山胡公求白鹇》"] +["雨顺风调百谷登,民不饥寒为上瑞。","苏轼《荔枝叹》"] +["帝高阳之苗裔兮,朕皇考曰伯庸。","屈原《离骚》"] +["莫倚倾国貌,嫁取个,有情郎。","李隆基《好时光·宝髻偏宜宫样》"] +["焦首朝朝还暮暮,煎心日日复年年。","《红楼梦·第二十二回》"] +["既张我弓,既挟我矢。","佚名《吉日》"] +["老信相如渴,贫忧曼倩饥。","许浑《早秋三首》"] +["巨鳌未斩海水动,鱼龙奔走安得宁。","李白《猛虎行》"] +["曾孙寿考,受天之祜。","佚名《信南山》"] +["行人莫上长堤望,风起杨花愁杀人。","李益《汴河曲》"] +["更值棠棣连阴,虎符熊轼,夹河分守。","李清照《长寿乐·南昌生日》"] +["念天地之悠悠,独怆然而涕下。","陈子昂《登幽州台歌》"] +["无聊成独卧,弹指韶光过。","纳兰性德《菩萨蛮·新寒中酒敲窗雨》"] +["将欲辞君挂帆去,离魂不散烟郊树。","李白《下途归石门旧居》"] +["欲觉闻晨钟,令人发深省。","杜甫《游龙门奉先寺》"] +["侍儿扶起娇无力,始是新承恩泽时。","白居易《长恨歌》"] +["皆云皇帝巡四国,烹灭强暴救黔首。","苏轼《石鼓歌》"] +["风情渐老见春羞,到处消魂感旧游。","李煜《赐宫人庆奴》"] +["夫子即琼树,倾柯拂羽仪。","李白《赠柳圆》"] +["彼姝者子,何以告之?","佚名《干旄》"] +["应作云南望乡鬼,万人冢上哭呦呦。","白居易《新丰折臂翁》"] +["鄂王坟上草离离,秋日荒凉石兽危。","赵孟頫《岳鄂王墓》"] +["转祸而为福,因败而为功。","《战国策·燕一·燕文公时》"] +["不洗残妆凭绣床,也同女伴绣鸳鸯。","无名氏《杂诗》"] +["修竹傍林开,乔松倚岩列。","德隐《新秋晚眺》"] +["苑方秦地少,山似洛阳多。","李白《金陵三首》"] +["消得几多风露,变教人世清凉。","刘克庄《清平乐·纤云扫迹》"] +["且君子之交淡若水,小人之交甘若醴。","《庄子·外篇·山木》"] +["去年折赠行人远,今年恨、依然纤手。","吴文英《花心动·柳》"] +["古之君子病其无能也,学之;今之君子耻其无能也,讳之。","《格言联璧·学问类》"] +["落日熔金,暮云合璧,人在何处。","李清照《永遇乐·落日熔金》"] +["虽是一般,惟高一着。","王旭《踏莎行·雪中看梅花》"] +["披衣出门应,府记欲得钱。","佚名《刺巴郡守诗》"] +["虽乖促席燕,白首信勿亏。","佚名《阻雪连句遥赠和》"] +["日销月铄就埋没,六年西顾空吟哦。","韩愈《石鼓歌》"] +["浪萍风梗诚何益。","柳永《归朝欢·别岸扁舟三两只》"] +["以若所为,求若所欲,犹缘木而求鱼也。","《孟子·梁惠王章句上·第八节》"] +["主上顷见征,欻然欲求伸。","杜甫《奉赠韦左丞丈二十二韵》"] +["梦冷蘅芜,却望姗姗,是耶非耶?","纳兰性德《沁园春·代悼亡》"] +["高帝子孙尽隆准,龙种自与常人殊。","杜甫《哀王孙》"] +["不积跬步,无以致千里;不积小流,无以成江海。","《太平御览·学部·卷一》"] +["言授之絷,以絷其马。","佚名《周颂·有客》"] +["谁把钿筝移玉柱,穿帘海燕双飞去。","冯延巳《鹊踏枝·六曲阑干偎碧树》"] +["明夜扁舟去,和月载离愁。","辛弃疾《水调歌头·落日古城角》"] +["君不见昔时燕家重郭隗,拥簪折节无嫌猜。","李白《行路难三首》"] +["儦儦俟俟,或群或友。","佚名《吉日》"] +["汉女输橦布,巴人讼芋田。","王维《送梓州李使君》"] +["醉后满身花影、倩人扶。","晏几道《虞美人·疏梅月下歌金缕》"] +["春态浅,来双燕,红日渐长一线。","和凝《喜迁莺·晓月坠》"] +["那时同唱鹧鸪词。","周紫芝《鹧鸪天·一点残红欲尽时》"] +["而我竟何为?寒苦坐相仍。","李白《赠新平少年》"] +["骨肉恩岂断,男儿死无时。","杜甫《前出塞九首》"] +["磨刀呜咽水,水赤刃伤手。","杜甫《前出塞九首》"] +["西施亦有弓藏惧,不独鸱夷变姓名。","吴伟业《一舸》"] +["移家避寇逐行舟,厌见南徐江水流。","李嘉祐《早秋京口旅泊章侍御寄书相问因以赠之时七夕》"] +["功成者去,觉团扇、便与人疏。","辛弃疾《汉宫春·会稽秋风亭观雨》"] +["路远莫致倚踟蹰,何为怀忧心烦纡。","张衡《四愁诗》"] +["寄身虽在远,岂忘君须臾。","徐干《室思》"] +["人亦有言:靡哲不愚,庶人之愚,亦职维疾。","佚名《抑》"] +["吴地桑叶绿,吴蚕已三眠。","李白《寄东鲁二稚子》"] +["当知器满则倾,须知物极必反。","《幼学琼林·卷三·人事》"] +["可使寸寸折,不能绕指柔。","白居易《李都尉古剑》"] +["皆言美无度,谁谓死如麻!","梅尧臣《范饶州坐中客语食河豚鱼》"] +["望断金马门,劳歌采樵路。","孟浩然《田园作》"] +["魂梦断,愁听漏更长。","薛昭蕴《小重山·秋到长门秋草黄》"] +["一寸柔肠情几许?薄衾孤枕,梦回人静,彻晓潇潇雨。","惠洪《青玉案·丝槐烟柳长亭路》"] +["遥望中原,荒烟外、许多城郭。","岳飞《满江红·登黄鹤楼有感》"] +["维曰予仕,孔棘且殆。","佚名《雨无正》"] +["离愁正引千丝乱,更东陌、飞絮蒙蒙。","张先《一丛花令·伤高怀远几时穷》"] +["淠彼泾舟,烝徒楫之。","佚名《棫朴》"] +["深知身在情长在,怅望江头江水声。","李商隐《暮秋独游曲江》"] +["凌烟功臣少颜色,将军下笔开生面。","杜甫《丹青引赠曹将军霸》"] +["齐子归止,其从如雨。","佚名《敝笱》"] +["同时辈流多上道,天路幽险难追攀。","韩愈《八月十五夜赠张功曹》"] +["金风细细。叶叶梧桐坠。","晏殊《清平乐·金风细细》"] +["南朝四百八十寺,多少楼台烟雨中。","杜牧《江南春》"] +["历数方未迁,云雷屡多难。","李白《南奔书怀》"] +["雨急云飞,惊散暮鸦,微弄凉月。","张元干《石州慢·己酉秋吴兴舟中作》"] +["相对真成泣楚囚,遂无末策到神州。","曾几《寓居吴兴》"] +["雷鼓嘈嘈喧武昌,云旗猎猎过寻阳。","李白《永王东巡歌十一首》"] +["缺月挂疏桐,漏断人初静。","苏轼《卜算子·黄州定慧院寓居作》"] +["微阳下乔木,远烧入秋山。","马戴《落日怅望》"] +["功被天下,守之以让。","《荀子·宥坐》"] +["红烛半条残焰短,依稀暗背锦屏。","尹鹗《临江仙·深秋寒夜银河静》"] +["霜殒芦花泪湿衣,白头无复倚柴扉。","与恭《思母》"] +["鸣桡轧轧溪溶溶,废绿平烟吴苑东。","温庭筠《莲浦谣》"] +["春草明年绿,王孙归不归?","王维《送别 / 山中送别 / 送友》"] +["颠之倒之,自公召之。","佚名《东方未明》"] +["故应菅蒯知心腹,弱缆能争万里风。","苏轼《慈湖夹阻风》"] +["山有枢,隰有榆。","佚名《山有枢》"] +["骤雨过,珍珠乱撒,打遍新荷。","元好问《骤雨打新荷》"] +["念兰堂红烛,心长焰短,向人垂泪。","晏殊《撼庭秋·别来音信千里》"] +["新叶初冉冉,初蕊新霏霏。","谢朓《咏落梅》"] +["见面怜清瘦,呼儿问苦辛。","蒋士铨《岁暮到家》"] +["池塘水绿风微暖。","晏殊《木兰花·池塘水绿风微暖》"] +["昏鸦尽,小立恨因谁?","纳兰性德《梦江南·昏鸦尽》"] +["红楼隔雨相望冷,珠箔飘灯独自归。","李商隐《春雨》"] +["心将熏麝焦,吟伴寒虫切。","贺铸《愁风月》"] +["为客裁缝君自见,城乌独宿夜空啼。","李白《庐江主人妇》"] +["有栈之车,行彼周道。","佚名《何草不黄》"] +["登舻美清夜,挂席移轻舟。","李白《月夜江行寄崔员外宗之》"] +["有酒有酒,闲饮东窗。","陶渊明《停云》"] +["一字无题处,落叶都愁。","张炎《八声甘州·记玉关踏雪事清游》"] +["橘林霜重更红鲜。","牛希济《临江仙·洞庭波浪颭晴天》"] +["人似秋鸿来有信,事如春梦了无痕。","苏轼《正月二十日与潘郭二生出郊寻春忽记去年是日同至女王城作诗乃和前韵》"] +["拔剑捎罗网,黄雀得飞飞。","曹植《野田黄雀行》"] +["黄鸟黄鸟,无集于桑,无啄我粱。","佚名《小雅·黄鸟》"] +["浩歌待明月,曲尽已忘情。","李白《春日醉起言志》"] +["香尘已隔犹回面。","晏殊《踏莎行·祖席离歌》"] +["艳阳景,妆点神州明媚。","柳永《长寿乐·繁红嫩翠》"] +["抱琴时弄月,取意任无弦。","李白《赠崔秋浦三首》"] +["盛者衰之始,福者祸之基。","《格言联璧·悖凶类》"] +["佳人在远遁,妾身单且茕。","曹植《杂诗七首》"] +["自在飞花轻似梦,无边丝雨细如愁。","秦观《浣溪沙·漠漠轻寒上小楼》"] +["绿酒初尝人易醉。一枕小窗浓睡。","晏殊《清平乐·金风细细》"] +["满腹诗书漫古今,频年流落易伤心。","宇文虚中《在金日作·选二》"] +["恐凤靴,挑菜归来,万一灞桥相见。","史达祖《东风第一枝·咏春雪》"] +["死为长白主,名字书绛阙。","苏轼《送欧阳推官赴华州监酒》"] +["积雪皓阴池,北风鸣细枝。","佚名《阻雪连句遥赠和》"] +["为谁零落为谁开。","王安石《浣溪沙·百亩中庭半是苔》"] +["君子求诸己,小人求诸人。","《论语·卫灵公篇》"] +["一枕清风昼睡馀。","苏轼《南乡子·自述》"] +["红颜未老恩先断,斜倚薰笼坐到明。","白居易《后宫词》"] +["鸡声茅店月,人迹板桥霜。","温庭筠《商山早行》"] +["无情最是台城柳,依旧烟笼十里堤。","韦庄《台城》"] +["试问谪仙何处?青山外,远烟碧。","韩元吉《霜天晓角·题采石蛾眉亭》"] +["今人犹歌李白诗,明月还如李白时。","唐寅《把酒对月歌》"] +["仓庚喈喈,采蘩祁祁。","佚名《出车》"] +["中冓之言,不可道也。","佚名《墙有茨》"] +["忽疑君到,漆灯风飐,痴数春星。","纳兰性德《青衫湿·悼亡》"] +["几时归去不销魂。","苏轼《浣溪沙·春情》"] +["孤眠鸾帐里,枉劳魂梦,夜夜飞扬。","佚名《凤归云·闺怨》"] +["怕人寻问,咽泪装欢。","唐婉《钗头凤·世情薄》"] +["岂是功成身合死,可怜事去言难赎。","文征明《满江红·拂拭残碑》"] +["水弄湘娥佩,竹啼山露月。","李贺《黄头郎》"] +["当时不好贤,岂传千古名。","李白《于五松山赠南陵常赞府》"] +["白发书生神州泪,尽凄凉、不向牛山滴。","刘克庄《贺新郎·九日》"] +["偏触动、词人系念。","顾太清《金缕曲·咏白海棠》"] +["我马维骐,六辔如丝。","佚名《皇皇者华》"] +["酒泉太守能剑舞,高堂置酒夜击鼓。","岑参《酒泉太守席上醉后作》"] +["天际遥山小,黛眉浅。","柳永《迷神引·一叶扁舟轻帆卷》"] +["父老四五人,问我久远行。","杜甫《羌村》"] +["野花芳草,寂寞关山道。","韦庄《清平乐·野花芳草》"] +["我是虏家儿,不解汉儿歌。","佚名《折杨柳歌辞五首》"] +["君子戒慎乎其所不睹,恐惧乎其所不闻。","《中庸·第一章》"] +["其未醉止,威仪抑抑。","佚名《宾之初筵》"] +["孝莫辞劳,转眼便为人父母;善因望报,回头但看尔儿孙。","《格言联璧·齐家类》"] +["钓鱼子陵,思莼季鹰,笑我飘零。","张可久《普天乐·秋怀》"] +["千里不难到,莫遣历块蹶。","苏轼《送欧阳推官赴华州监酒》"] +["百年强半,高秋犹在天南畔。","胡铨《醉落魄·辛未九月望和答庆符》"] +["五千言、老来受用,肯教造物儿戏?","姜夔《永遇乐·次韵辛克清先生》"] +["算得人间天上,惟有两心同。","柳永《集贤宾·小楼深巷狂游遍》"] +["是究是图,亶其然乎?","佚名《常棣》"] +["甘居人下者鲜。御之失谋,非犯,则篡耳。","《罗织经·治下卷第三》"] +["古庙杉松巢水鹤,岁时伏腊走村翁。","杜甫《咏怀古迹五首·其四》"] +["地中有山,谦;君子以裒多益寡,称物平施。","《易传·象传上·谦》"] +["二十年重过南楼。","刘过《唐多令·芦叶满汀洲》"] +["皇帝二载秋,闰八月初吉。","杜甫《北征》"] +["怕上层楼,十日九风雨。","辛弃疾《祝英台近·晚春》"] +["帘幕风轻双语燕。","晏殊《蝶恋花·帘幕风轻双语燕》"] +["六曲屏山深院宇。","纳兰性德《清平乐·将愁不去》"] +["幽花香涧谷,寒藻舞沦漪。","苏轼《临江仙·风水洞作》"] +["我心何怫郁,思欲一东归。","曹操《苦寒行》"] +["俭则伤事,侈则伤货","《管子·乘马》"] +["君王若道能倾国,玉辇何由过马嵬。","李商隐《马嵬二首》"] +["逢君发花萼,若与青云齐。","李白《赠从弟冽》"] +["金风荡初节,玉露凋晚林。","李密《淮阳感怀》"] +["盛年不重来,一日难再晨。","陶渊明《杂诗·人生无根蒂》"] +["密锁重关掩绿苔,廊深阁迥此徘徊。","李商隐《正月崇让宅》"] +["惭愧流莺相厚意,清晨犹为到西园。","韩偓《春尽》"] +["天与短因缘,聚散常容易。","晏几道《生查子·狂花顷刻香》"] +["怨粉愁香绕砌多,大风一起奈卿何。","吴信辰《咏虞美人花》"] +["故乡窅窅日夜隔,音尘断绝阻河关。","鲍照《拟行路难十八首》"] +["静以修身,俭以养福。入则笃行,出则友贤。","《格言联璧·惠吉类》"] +["同是天涯沦落人,相逢何必曾相识!","白居易《琵琶行 / 琵琶引》"] +["落叶满空山,何处寻行迹。","韦应物《寄全椒山中道士》"] +["中夜间道归,故里但空村。","杜甫《后出塞五首》"] +["闺中风暖,陌上草薰。","江淹《别赋》"] +["一叶舟轻,双桨鸿惊。","苏轼《行香子·过七里濑》"] +["翡翠楼边悬玉镜,珍珠帘外挂冰盘。","曹雪芹《香菱咏月·其一》"] +["势利使人争,嗣还自相戕。","曹操《蒿里行》"] +["三杯通大道,一斗合自然。","李白《月下独酌四首》"] +["政之于民,过急则 刻薄,伤缓则弛慢。","《周书·列传·卷四十五》"] +["落日无情最有情,遍催万树暮蝉鸣。","杨万里《初秋行圃》"] +["莫恃朝荣好,君看暮落时。","刘庭琦《咏木槿树题武进文明府厅》"] +["云压西村茅舍重,怕他榾柮同烧。","陈维崧《临江仙·寒柳》"] +["盐车上峻坂,倒行逆施畏日晚。","李白《天马歌》"] +["禅边风味客边愁,馈我清光又满楼。","慧霖《闰中秋玩月》"] +["茅舍疏篱今在否,松竹已非畴昔。","辛弃疾《念奴娇·西湖和人韵》"] +["天寒翠袖薄,日暮倚修竹。","杜甫《佳人》"] +["飞来衔得樱桃去。","王国维《蝶恋花·窗外绿阴添几许》"] +["蛇毒浓凝洞堂湿,江鱼不食衔沙立。","李贺《罗浮山父与葛篇》"] +["杨花落,燕子横穿朱阁。","李清臣《谒金门·杨花落》"] +["风檐展书读,古道照颜色。","文天祥《正气歌》"] +["庆人间七夕佳令。","卢挚《沉醉东风·七夕》"] +["比于文王,其德靡悔。","佚名《皇矣》"] +["沧海得壮士,椎秦博浪沙。","李白《经下邳圯桥怀张子房》"] +["江南几日又天涯,谁与寄相思?","董士锡《木兰花慢·武林归舟中作》"] +["柴门车辙冻,日下榆影瘦。","李贺《赠陈商》"] +["征马去翩翩,城秋月正圆。","崔颢《送单于裴都护赴西河》"] +["秋侵人影瘦,霜染菊花肥。","《浮生六记·闺房记乐》"] +["榴花不似舞裙红。","陈与义《临江仙·高咏楚词酬午日》"] +["车酋轩下东越,列郡苏疲羸。","柳宗元《哭连州凌员外司马》"] +["太谷何寥廓,山树郁苍苍。","曹植《赠白马王彪·并序》"] +["羔裘豹饰,孔武有力。","佚名《羔裘》"] +["多谢无功,此事如何着得侬。","苏轼《减字木兰花·维熊佳梦》"] +["绛帻鸡人报晓筹,尚衣方进翠云裘。","王维《和贾舍人早朝大明宫之作》"] +["梦入家门上沙渚,天河落处长洲路。","李贺《宫娃歌》"] +["失了白衣苍狗,夺回雪兔金蟆。","刘克庄《木兰花慢·丁未中秋》"] +["苑方秦地少,山似洛阳多。","李白《金陵三首》"] +["至道深微,惟人是弘","《南史·本纪·卷四》"] +["于以用之?公侯之事。","佚名《采蘩》"] +["歌舞尊前,繁华镜里,暗换青青发。","萨都剌《念奴娇·登石头城次东坡韵》"] +["水清莲媚两相向,镜里见愁愁更红。","温庭筠《莲浦谣》"] +["由醉之言,俾出童羖。","佚名《宾之初筵》"] +["有北不受,投畀有昊!","佚名《巷伯》"] +["相顾无言,惟有泪千行。","苏轼《江城子·乙卯正月二十日夜记梦》"] +["老来疾病都是壮时招得;衰时罪孽都是盛时作得。","《菜根谭·概论》"] +["着屐苍苔细石间,野兴横生。","姜夔《一萼红·古城阴》"] +["徒念关山近,终知返路长。","谢朓《暂使下都夜发新林至京邑赠西府同僚》"] +["家家习为俗,人人迷不悟。","白居易《买花 / 牡丹》"] +["亲昵并集送,置酒此河阳。","曹植《送应氏二首》"] +["泽国从一官,沧波几千里。","岑参《送王大昌龄赴江宁》"] +["褒公鄂公毛发动,英姿飒爽来酣战。","杜甫《丹青引赠曹将军霸》"] +["风日薄、度墙啼鸟声乱。","卢祖皋《宴清都·初春》"] +["春归归不得,两桨松花隔。","纳兰性德《菩萨蛮·问君何事轻离别》"] +["见人不正,虽贵不敬也;见人有污,虽尊不下也。","《史记·日者列传》"] +["扣舷惊翡翠,嫩玉抬香臂。","孙光宪《菩萨蛮·青岩碧洞经朝雨》"] +["西城杨柳弄春柔。动离忧。泪难收。","秦观《江城子·西城杨柳弄春柔》"] +["步屟随春风,村村自花柳。","杜甫《遭田父泥饮美严中丞》"] +["居常土思兮心内伤,愿为黄鹄兮归故乡。","刘细君《悲秋歌》"] +["伤心桥下春波绿,曾是惊鸿照影来。","陆游《沈园二首》"] +["万里交河水北流,愿为双燕泛中洲。","李白《捣衣篇》"] +["日月忽其不淹兮,春与秋其代序。","屈原《离骚》"] +["倚云低首望,可知心?","鹿虔扆《女冠子·凤楼琪树》"] +["草木也知愁,韶华竟白头。","曹雪芹《唐多令·柳絮》"] +["犹有南朝旧碑在,耻将兴废问休公。","温庭筠《开圣寺》"] +["天上神仙府,人间宰相家","《儒林外史·第十回》"] +["对酒当歌,强乐还无味。","柳永《蝶恋花·伫倚危楼风细细》"] +["棠梨叶落胭脂色,荞麦花开白雪香。","王禹偁《村行·马穿山径菊初黄》"] +["人生譬朝露,居世多屯蹇。","秦嘉《赠妇诗三首》"] +["年年社日停针线。怎忍见、双飞燕。","黄公绍《青玉案·年年社日停针线》"] +["残桃间堕井,新菊亦侵篱。","许浑《早秋三首》"] +["如梦如烟,枝上花开又十年!","龚自珍《减字木兰花·偶检丛纸中》"] +["出门万里客,中道逢嘉友。","陶渊明《拟古九首》"] +["终日见征战,连年闻鼓鼙。","岑参《早发焉耆怀终南别业》"] +["渔市孤烟袅寒碧,水村残叶舞愁红。","柳永《雪梅香·景萧索》"] +["留得枯荷,奈他先作离声。","郭麐《高阳台·将反魏塘疏香女子亦以次日归吴下置酒话别离怀惘惘》"] +["萧萧梧叶送寒声,江上秋风动客情。","叶绍翁《夜书所见》"] +["明珠归合浦,应逐使臣星。","王维《送邢桂州》"] +["因风吹火,用力不多。","《增广贤文·上集》"] +["淮南好,甚时重到?陌上生春草。","姜夔《点绛唇·金谷人归》"] +["滕王高阁临江渚,佩玉鸣鸾罢歌舞。","王勃《滕王阁诗》"] +["檀郎谢女眠何处?楼台月明燕夜语。","李贺《牡丹种曲》"] +["漠漠轻阴晚自开,青天白日映楼台。","韩愈《同水部张员外籍曲江春游寄白二十二舍人》"] +["明晨秉机杼,日昃不成文。","曹植《杂诗七首》"] +["年年负却花期!过春时,只合安排愁绪送春归。","张惠言《相见欢·年年负却花期》"] +["路不周以左转兮,指西海以为期。","屈原《离骚》"] +["飞絮飞花何处是,层冰积雪摧残,疏疏一树五更寒。","纳兰性德《临江仙·寒柳》"] +["街鼓动,禁城开,天上探人回。","韦庄《喜迁莺·街鼓动》"] +["最苦浔阳江头客,画舸亭亭待发。","辛弃疾《贺新郎·赋琵琶》"] +["困倚危楼。过尽飞鸿字字愁。","秦观《减字木兰花·天涯旧恨》"] +["流沙丹灶灭,关路紫烟沉。","李白《谒老君庙》"] +["水茫茫,平沙雁、旋惊散。","柳永《迷神引·一叶扁舟轻帆卷》"] +["角声满天秋色里,塞上燕脂凝夜紫。","李贺《雁门太守行》"] +["小径红稀,芳郊绿遍。","晏殊《踏莎行·小径红稀》"] +["愁闻出塞曲,泪满逐臣缨。","李白《观胡人吹笛》"] +["星转斗,驾回龙。","向子諲《鹧鸪天·紫禁烟花一万重》"] +["以兹误生理,独耻事干谒。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["无家对寒食,有泪如金波。","杜甫《一百五日夜对月》"] +["碧云日暮无书寄,寥落烟中一雁寒。","范成大《鹧鸪天·休舞银貂小契丹》"] +["维鹊有巢,维鸠方之。","佚名《鹊巢》"] +["岂力能制敌与?盖蒙其伤怜而已。","元结《贼退示官吏》"] +["陇头流水,流离山下。","佚名《陇头歌辞三首》"] +["青云动高兴,幽事亦可悦。","杜甫《北征》"] +["遥想公瑾当年,小乔初嫁了,雄姿英发。","苏轼《念奴娇·赤壁怀古》"] +["酒未开樽句未裁,寻春问腊到蓬莱。","曹雪芹《访妙玉乞红梅》"] +["昔日太宗拳毛騧,近时郭家狮子花。","杜甫《韦讽录事宅观曹将军画马图》"] +["信者人之干也,义者行之节也。","《列女传·贞顺传·陈寡孝妇》"] +["稍待秋风凉冷后,高寻白帝问真源。","杜甫《望岳三首·其二》"] +["凡失其所欲之路而妄行者之谓迷","《韩非子·解老》"] +["散尽空掉臂,高歌赋还邛。","李白《魏郡别苏明府因北游》"] +["红笺向壁字模糊,忆共灯前呵手为伊书。","纳兰性德《虞美人·愁痕满地无人省》"] +["荆江日落阵云低,横戈跃马今何时。","纳兰性德《送荪友》"] +["花钿委地无人收,翠翘金雀玉搔头。","白居易《长恨歌》"] +["今看摇落,凄怆江潭:树犹如此,人何以堪?","姜夔《长亭怨慢·渐吹尽》"] +["天教心愿与身违。","李煜《浣溪沙·转烛飘蓬一梦归》"] +["任数重鸳被,怎向孤眠不暖。","柳永《安公子·梦觉清宵半》"] +["弱冠弄柔翰,卓荦观群书。","左思《咏史八首·其一》"] +["星月开天阵,山川列地营。","陈子昂《和陆明府赠将军重出塞》"] +["雕盘绮食会众客,吴歌赵舞香风吹。","李白《扶风豪士歌》"] +["圣人赫斯怒,诏伐西南戎。","高适《李云南征蛮诗》"] +["知祢不能荐,羞为献纳臣。","王维《送丘为落第归江东》"] +["母欺子,子而不信其母,非以成教也。","《韩非子·外储说左上》"] +["极目楚天空,云雨无踪,漫留遗恨锁眉峰。","幼卿《浪淘沙·极目楚天空》"] +["湔裙梦断续应难。","纳兰性德《临江仙·寒柳》"] +["画堂人静雨蒙蒙,屏山半掩余香袅。","寇准《踏莎行·春暮》"] +["物情良可见,人事不胜悲。","刘庭琦《咏木槿树题武进文明府厅》"] +["卷帘风意恶,泪与残红落。","徐灿《菩萨蛮·春闺》"] +["闻道黄龙戍,频年不解兵。","沈佺期《杂诗三首·其三》"] +["春思乱,芳心碎。","惠洪《千秋岁·半身屏外》"] +["处世让一步为高,退步即进步的张本","《菜根谭·概论》"] +["一重帘外即天涯。","许棐《喜迁莺·鸠雨细》"] +["若使当时身不遇,老了英雄。","王安石《浪淘沙令·伊吕两衰翁》"] +["塔势如涌出,孤高耸天宫。","岑参《与高适薛据同登慈恩寺浮图》"] +["一洗闲愁十五年。","苏轼《减字木兰花·银筝旋品》"] +["苏武才为典属国,节旄空尽海西头。","王维《陇头吟》"] +["马影鸡声,谙尽倦邮荒馆。","张翥《陌上花·有怀》"] +["浩叹杨朱子,徒然泣路岐。","张九龄《感遇十二首》"] +["茅檐相对坐终日,一鸟不鸣山更幽。","王安石《钟山即事》"] +["辞却江南三月,何处梦堪温?","夏完淳《婆罗门引·春尽夜》"] +["凤楼琪树,惆怅刘郎一去,正春深。","鹿虔扆《女冠子·凤楼琪树》"] +["到帐动风开自有神。","吴文英《沁园春·送翁宾旸游鄂渚》"] +["各勉日新志,音尘慰寂蔑。","谢灵运《邻里相送至方山》"] +["贤圣既已饮,何必求神仙。","李白《月下独酌四首》"] +["裂石穿云,玉管宜横清更洁。","白朴《驻马听·吹》"] +["悬知一顾重,别觉舞腰轻。","徐惠《赋得北方有佳人》"] +["上士闻道,勤而行之;中士闻道,若存若亡;下士闻道,大笑之。","《老子·德经·第四十一章》"] +["说和说战都难,算未必江沱堪宴安。","陈人杰《沁园春·丁酉岁感事》"] +["兰秋香风远,松寒不改容。","李白《于五松山赠南陵常赞府》"] +["孝孙徂位,工祝致告。","佚名《楚茨》"] +["为谁归去为谁来,主人恩重珠帘卷。","陈尧佐《踏莎行·二社良辰》"] +["如梦如烟,枝上花开又十年!","龚自珍《减字木兰花·偶检丛纸中》"] +["八月湖水平,涵虚混太清。","孟浩然《望洞庭湖赠张丞相 / 临洞庭》"] +["密叶隐歌鸟,香风留美人。","李白《紫藤树》"] +["积薄而为厚,聚少而为多","《战国策·秦四·或为六国说秦王》"] +["明年此会知谁健?醉把茱萸仔细看。","杜甫《九日蓝田崔氏庄》"] +["一闻歌玉树,萧瑟后庭秋。","李白《月夜金陵怀古》"] +["松江上,念故人老矣,甘卧闲云。","吴文英《沁园春·送翁宾旸游鄂渚》"] +["唐剑斩隋公,拳毛属太宗。","李贺《马诗二十三首》"] +["早月多情,送过梨花影。","纳兰性德《鬓云松令·咏浴》"] +["晓云连幕卷,夜火杂星回。","宋之问《扈从登封途中作》"] +["情欲意识,尽属妄心消杀得,妄心尽而后真心现。","《菜根谭·概论》"] +["临水整乌纱。两鬓苍华。故乡心事在天涯。","吴琚《浪淘沙·岸柳可藏鸦》"] +["甚愧丈人厚,甚知丈人真。","杜甫《奉赠韦左丞丈二十二韵》"] +["运命唯所遇,循环不可寻。","张九龄《感遇十二首》"] +["山随平野尽,江入大荒流。","李白《渡荆门送别》"] +["王母欢阑琼宴罢,仙人泪满金盘侧。","文天祥《满江红·代王夫人作》"] +["白狼河北音书断,丹凤城南秋夜长。","沈佺期《古意呈补阙乔知之 / 古意 / 独不见》"] +["旗亭下马解秋衣,请贳宜阳一壶酒。","李贺《开愁歌》"] +["此翁白头真可怜,伊昔红颜美少年。","刘希夷《代悲白头翁》"] +["约在春归,夏来依旧各西东。","佚名《玉蝴蝶·为甚夜来添病》"] +["民惟邦本,本固邦宁。","《尚书·夏书·五子之歌》"] +["腊月草根甜,天街雪似盐。","李贺《马诗二十三首》"] +["秦淮有水水无情,还向金陵漾春色。","温庭筠《春江花月夜词》"] +["有兔爰爰,雉离于罗。","佚名《兔爰》"] +["少年心事当拿云,谁念幽寒坐呜呃。","李贺《致酒行》"] +["我心忧伤,念昔先人。","佚名《小宛》"] +["华发萧萧,对荒园搔首。","苏轼《醉蓬莱·重九上君猷》"] +["西园清夜片尘无。","张孝祥《临江仙·试问梅花何处好》"] +["停画桡、两两舟人语。","柳永《安公子·远岸收残雨》"] +["志闲而少欲,心安而不惧,形劳而不倦,气从以顺,各从其欲,皆得所愿。","《黄帝内经·上古天真论》"] +["持谢邻家子,效颦安可希。","王维《西施咏》"] +["惭将赤手分三席,敢为丹心借一枝。","张煌言《甲辰八月辞故里 \\ 将入武林》"] +["有周不显,帝命不时。","佚名《文王》"] +["山花插宝髻,石竹绣罗衣。","李白《宫中行乐词八首》"] +["白日如分照,还归守故园。","李白《流夜郎题葵叶》"] +["保有厥士,于以四方,克定厥家。","佚名《周颂·桓》"] +["起望衣冠神州路,白日销残战骨。","辛弃疾《贺新郎·用前韵送杜叔高》"] +["直道事人,虚衷御物。","《格言联璧·接物类》"] +["白发星星筋力衰,种田犹自伴孙儿。","杜荀鹤《田翁》"] +["尊酒何人怀李白,草堂遥指江东。","苏轼《临江仙·夜到扬州席上作》"] +["邦君诸侯,莫肯朝夕。","佚名《雨无正》"] +["纱厨藤簟,玉人罗扇轻缣。","白朴《天净沙·夏》"] +["剜苔剔藓露节角,安置妥帖平不颇。","韩愈《石鼓歌》"] +["卬明月而太息兮,步列星而极明。","宋玉《九辩》"] +["独伤千载后,空余松柏林。","李白《谒老君庙》"] +["铁锁横江,锦帆冲浪,孙郎良苦。","朱敦儒《水龙吟·放船千里凌波去》"] +["只欠翠纱红映肉,两年寒食负先生。","杨万里《春晴怀故园海棠二首》"] +["枯藤老树昏鸦,小桥流水人家,古道西风瘦马。","马致远《天净沙·秋思》"] +["爱向竹栏骑竹马,懒于金地聚金沙。","金地藏《送童子下山》"] +["晴川历历汉阳树,芳草萋萋鹦鹉洲。","崔颢《黄鹤楼 / 登黄鹤楼》"] +["玄漠云平初合阵,西山月出闻鸣镝。","李昂《从军行》"] +["稷下多谈士,指彼决吾疑。","陶渊明《拟古九首》"] +["画栋朝飞南浦云,珠帘暮卷西山雨。","王勃《滕王阁诗》"] +["我欲乘风去,击楫誓中流。","张孝祥《水调歌头·和庞佑父》"] +["郁陶思君未敢言,寄声浮云往不还。","曹丕《燕歌行》"] +["长空去鸟没,落日孤云还。","李白《春日独酌二首》"] +["江山谢守高吟地,风月朱公故里情。","晏殊《送凌侍郎还宣州》"] +["清浊易辨,邪正难辨。","《冰鉴·神骨》"] +["王风委蔓草,战国多荆榛。","李白《古风·大雅久不作》"] +["一品与千金,问白发、如何回避?","范仲淹《剔银灯·与欧阳公席上分题》"] +["吴歈越吟未终曲,江上团团贴寒玉。","李贺《江南弄》"] +["政之所兴,在顺民心。","《管子·牧民》"] +["捧盘出户歌一声,市楼东西人未行。","张耒《示秬秸》"] +["顷刻光阴都过了,如今绿暗红英少。","欧阳修《蝶恋花·尝爱西湖春色早》"] +["兰红波碧忆潇湘。","孙光宪《浣溪沙·蓼岸风多橘柚香》"] +["两鬓入秋浦,一朝飒已衰。","李白《秋浦歌十七首》"] +["大罗天上月朦胧,骑马上虚空。","韦庄《喜迁莺·人汹汹》"] +["过门走马何官职,侧帽笼鞭战北风!","范成大《冬日田园杂兴》"] +["父母在,不远游,游必有方。","《论语·里仁篇》"] +["悬知寒食朝陵使,驿路梨花处处开。","陆游《闻武均州报已复西京》"] +["似名花并蒂,日日醉春浓。","吴文英《高山流水·丁基仲侧室善丝桐赋咏晓达音吕备歌舞之妙》"] +["绕严陵滩畔,鹭飞鱼跃。","柳永《满江红·暮雨初收》"] +["庙堂无计可平戎,坐使甘泉照夕峰。","陈与义《伤春》"] +["欲说当年,望湖楼下,水与云宽窄。","辛弃疾《念奴娇·西湖和人韵》"] +["虽不周于今之人兮,愿依彭咸之遗则。","屈原《离骚》"] +["寄言俦侣,莫负广寒沈醉。","文征明《念奴娇·中秋对月》"] +["浑河水、一线如虹。","顾太清《高山流水·次夫子清风阁落成韵》"] +["迢递嵩高下,归来且闭关。","王维《归嵩山作》"] +["转尽青天粟,无路能通。","王质《八声甘州·读诸葛武侯传》"] +["问渠哪得清如许?为有源头活水来。","朱熹《活水亭观书有感二首·其一》"] +["乾坤大,霜林独坐,红叶纷纷堕。","王国维《点绛唇·厚地高天》"] +["荷笠带斜阳,青山独归远。","刘长卿《送灵澈上人》"] +["一春略无十日晴,处处浮云将雨行。","汪藻《春日·一春略无十日晴》"] +["兴亡千古繁华梦,诗眼倦天涯。","张可久《人月圆·山中书事》"] +["日暖泥融雪半消,行人芳草马声骄。","杜牧《宣州送裴坦判官往舒州时牧欲赴官归京》"] +["行人便觉须眉绿,一路蝉声过许州。","沈德潜《过许州》"] +["国有贤相良将,民之师表也。","《史记·七十列传·太史公自序》"] +["联步趋丹陛,分曹限紫微。","岑参《寄左省杜拾遗》"] +["过雨西风,数叶井梧愁舞。","吴文英《荔枝香近·七夕》"] +["疆理虽重海,车书本一家。","温庭筠《送渤海王子归本国》"] +["同来野僧六七辈,亦有文士相追随。","黄庭坚《书摩崖碑后》"] +["不对芳春酒,还望青山郭。","谢朓《游东田》"] +["劝伊好向红窗醉,须莫及,落花时。","纳兰性德《落花时·夕阳谁唤下楼梯》"] +["但听雨挑灯,攲床病酒,多梦睡时妆。","史达祖《寿楼春·寻春服感念》"] +["宝帘闲挂小银钩。","秦观《浣溪沙·漠漠轻寒上小楼》"] +["耿耿忆琼树,天涯寄一欢。","李白《三山望金陵寄殷淑》"] +["我来牛渚,聊登眺、客里襟怀如豁。","吴渊《念奴娇·我来牛渚》"] +["铜雀台成玉座空,短歌长袖尽悲风。","汪遵《咏铜雀台》"] +["烟里黄沙遮不住,河流日夜东南注。","周济《蝶恋花·柳絮年年三月暮》"] +["雨雪瀌瀌,见晛曰消。","佚名《角弓》"] +["盼到园花铺似绣,却更比春前瘦。","纳兰性德《四和香·麦浪翻晴风飐柳》"] +["新书报我添憔悴。","苏轼《蝶恋花·昨夜秋风来万里》"] +["今日明光里,还须结伴游。","李白《宫中行乐词八首》"] +["竹实满秋浦,凤来何苦饥。","李白《赠柳圆》"] +["是究是图,亶其然乎?","佚名《常棣》"] +["只有梧桐枝上,留得三分。","夏完淳《婆罗门引·春尽夜》"] +["我觉山高,潭空水冷,月明星淡。","辛弃疾《水龙吟·过南剑双溪楼》"] +["有情终古似无情,别语悔分明。","纳兰性德《荷叶杯·知己一人谁是》"] +["既见君子,云胡不喜。","佚名《风雨》"] +["怪人间厚福,天公尽付,痴儿騃女。","纳兰性德《水龙吟·题文姬图》"] +["上之所以使下者,一物也;下之所以事上者,一术也。","《墨子·08章 尚贤(上)》"] +["白日当天心,照之可以事明主。","李白《临江王节士歌》"] +["绿杨芳草长亭路。年少抛人容易去。","晏殊《玉楼春·春恨》"] +["性孤高似柏,阿娇金屋。","陆容《满江红·咏竹》"] +["未穷激楚乐,已见高台倾。","虞羲《咏霍将军北伐》"] +["妾弄青梅凭短墙,君骑白马傍垂杨。","白居易《井底引银瓶·止淫奔也》"] +["月出致讥,贻愧皓首。","李白《雪谗诗赠友人》"] +["日中则移,月满则亏。","《战国策·秦三·蔡泽见逐于赵》"] +["宛溪霜夜听猿愁,去国长如不系舟。","李白《寄崔侍御》"] +["云影摇寒,波尘锁腻,翠房人去深扃。","吴文英《庆春宫·秋感》"] +["寂寞风蝉至,连翩霜雁来。","阴行先《和张燕公湘中九日登高》"] +["结交嬴台女,吟弄升天行。","陈子昂《与东方左史虬修竹篇》"] +["望蔚然深秀,琅琊山也。","黄庭坚《瑞鹤仙·环滁皆山也》"] +["美酒樽中置千斛,载妓随波任去留。","李白《江上吟》"] +["清明又近也,却天涯为客。","曹组《忆少年·年时酒伴》"] +["乱山深处水萦回,可惜一枝如画为谁开?","秦观《虞美人·碧桃天上栽和露》"] +["玉皇开碧落,银界失黄昏。","毛滂《临江仙·都城元夕》"] +["透内阁香风阵阵,掩重门暮雨纷纷。","王实甫《十二月过尧民歌·别情》"] +["鼓咽咽,醉言归。","佚名《鲁颂·有駜》"] +["时时闻鸟语,处处是泉声。","白居易《遗爱寺》"] +["无人收废帐,归马识残旗。","张籍《没蕃故人》"] +["感时花溅泪,恨别鸟惊心。","杜甫《春望》"] +["高议而不可及,不若卑论之有功也。","《说苑·谈丛》"] +["至今云雨带愁容,月斜江上,征棹动晨钟。","牛希济《临江仙·峭壁参差十二峰》"] +["寻常一样窗前月,才有梅花便不同。","杜耒《寒夜》"] +["闲寻旧踪迹,又酒趁哀弦,灯照离席。","周邦彦《兰陵王·柳》"] +["好事不出门,坏事传千里。","《增广贤文·上集》"] +["时赛敬亭神,复解罟师网。","王维《送宇文太守赴宣城》"] +["长大逃深室,藏头羞见人。","傅玄《豫章行苦相篇》"] +["开函关,掩函关,千古如何不见一人闲?","贺铸《将进酒·城下路》"] +["我行不记日,误作阳春时。","李白《白田马上闻莺》"] +["空园白露滴,孤壁野僧邻。","马戴《灞上秋居》"] +["一担乾柴古渡头,盘缠一日颇优游。","萧德藻《樵夫》"] +["公徒三万,贝胄朱綅。","佚名《鲁颂·閟宫》"] +["五湖闻道,扁舟归去,仍携西子。","苏轼《水龙吟·黄州梦过栖霞楼》"] +["望望不见君,连山起烟雾。","李白《金乡送韦八之西京》"] +["心乖甚水火,百恶集其身。","傅玄《豫章行苦相篇》"] +["白屋可能无孺子,黄堂不是欠陈蕃。","黄庭坚《徐孺子祠堂》"] +["图南未可料,变化有鲲鹏。","杜甫《泊岳阳城下 / 泊岳阳楼下》"] +["可怜无定河边骨,犹是春闺梦里人!","陈陶《陇西行四首·其二》"] +["中庭生旅谷,井上生旅葵。","佚名《十五从军征》"] +["外湖莲子长参差,霁山青处鸥飞。","张先《画堂春·外湖莲子长参差》"] +["官闲我欲频来此,枕簟仍教到处随。","陆游《东湖新竹》"] +["西得诸侯棹锦水,欲向何门趿珠履?","杜甫《短歌行赠王郎司直》"] +["畚锸载埋瘗,沟渎护其危。","柳宗元《掩役夫张进骸》"] +["把君诗卷灯前读,诗尽灯残天未明。","白居易《舟中读元九诗》"] +["不见年年辽海上,文章何处哭秋风?","李贺《南园十三首》"] +["时时一大笑,此必相嘲嗤。","刘禹锡《插田歌》"] +["共说春来春去事,多时。一点愁心入翠眉。","晏几道《南乡子·花落未须悲》"] +["忽如朝玉皇,天冕垂前旒。","柳宗元《界围岩水帘》"] +["峰峦如聚,波涛如怒,山河表里潼关路。","张养浩《山坡羊·潼关怀古》"] +["俾尔多益,以莫不庶。","佚名《天保》"] +["收泪即长路,援笔从此辞。","曹植《赠白马王彪·并序》"] +["烟敛云收,依约是湘灵。","苏轼《江城子·江景》"] +["山桃红花满上头,蜀江春水拍山流。","刘禹锡《竹枝词·山桃红花满上头》"] +["清秋幕府井梧寒,独宿江城蜡炬残。","杜甫《宿府》"] +["水调数声持酒听。午醉醒来愁未醒。","张先《天仙子·水调数声持酒听》"] +["何时旦,晓星欲散,飞起平沙雁。","纳兰性德《点绛唇·黄花城早望》"] +["过桥分野色,移石动云根。","贾岛《题李凝幽居》"] +["不患寡而患不均,不患贫而患不安。盖均无贫,和无寡,安无倾。","《论语·季氏篇》"] +["吾辈道何穷,寒山细雨中。","杜荀鹤《秋日山中寄李处士》"] +["六朝旧事随流水,但寒烟衰草凝绿。","王安石《桂枝香·金陵怀古》"] +["忿如火,不遏则燎原;欲如水,不遏则滔天。","《格言联璧·存养类》"] +["君记取、封侯事在。","陆游《汉宫春·初自南郑来成都作》"] +["相将初试红盐味,到烟雨、青黄时节。","吴文英《暗香疏影·夹钟宫赋墨梅》"] +["腰间剑,聊弹铗。","辛弃疾《满江红·汉水东流》"] +["听妇前致词,三男邺城戍。","杜甫《石壕吏》"] +["古苔泪锁霜千点,苍华人共老。","吴文英《花犯·谢黄复庵除夜寄古梅枝》"] +["王在在镐,有那其居。","佚名《鱼藻》"] +["汉家能用武,开拓穷异域。","高适《杂曲歌辞·蓟门行五首》"] +["纸屏石枕竹方床,手倦抛书午梦长。","蔡确《夏日登车盖亭》"] +["叹幽欢未足,何事轻弃。","姜夔《解连环·玉鞭重倚》"] +["一春不识西湖面。翠羞红倦。","许棐《后庭花·一春不识西湖面》"] +["人事回廊缥缈,谁见金钗擘。","吴文英《六么令·夷则宫七夕》"] +["桐叶落,蓼花残。雁声天外寒。","张淑芳《更漏子·秋》"] +["君有妒臣,则贤人不至。","《荀子·大略》"] +["阑干倒、千红妆靥,铅香不断。","吴文英《瑞龙吟·德清清明竞渡》"] +["之子于归,百两将之。","佚名《鹊巢》"] +["燕笑语兮,是以有誉处兮。","佚名《蓼萧》"] +["花似雪,东风夜扫苏堤月。","方以智《忆秦娥·花似雪》"] +["圣处一灯传,工夫萤雪边。","辛弃疾《菩萨蛮·送曹君之庄所》"] +["箫鼓鸣兮发棹歌,欢乐极兮哀情多。","刘彻《秋风辞》"] +["辽东万里辽水曲,古戍无城复无屋。","王建《辽东行》"] +["不学游侠儿,矜夸紫骝好。","王昌龄《塞下曲四首》"] +["北邙未省留闲地,东海何曾有顶波。","白居易《放言五首·其四》"] +["通塘不忍别,十去九迟回。","李白《和卢侍御通塘曲》"] +["王事靡盬,不遑将父。","佚名《四牡》"] +["有杕之杜,其叶菁菁。","佚名《杕杜》"] +["善人同处,则日闻嘉训;恶人从游,则日生邪情。","《后汉书·列传·杨李翟应霍爰徐列传》"] +["暮潮风正急,酒阑闻塞笛。","韩元吉《霜天晓角·题采石蛾眉亭》"] +["一之日觱发,二之日栗烈。","佚名《七月》"] +["直不犯祸,和不害义。","《格言联璧·持躬类》"] +["君子有酒,旨且多。","佚名《鱼丽》"] +["耿耿残灯背壁影,萧萧暗雨打窗声。","白居易《上阳白发人》"] +["亡国生春草,王宫没古丘。","李白《金陵三首》"] +["何似伯鸾携德耀,箪瓢未足清欢足。","苏轼《满江红·忧喜相寻》"] +["情未已,信曾通,满衣犹自染檀红。","欧阳炯《献衷心·见好花颜色》"] +["自谓颇挺出,立登要路津。","杜甫《奉赠韦左丞丈二十二韵》"] +["最爱芦花经雨后,一蓬烟火饭鱼船。","林逋《咏秋江》"] +["怪当年《甘泉》误说,青葱玉树。","辛弃疾《贺新郎·和徐斯远下第谢诸公载酒相访韵》"] +["夜凉枕簟已知秋,更听寒蛩促机杼。","苏轼《木兰花令·梧桐叶上三更雨》"] +["仁者以财发身,不仁者以身发财。","《大学·第十一章》"] +["晚酣留客舞,凫舄共差池。","杜甫《九日杨奉先会白水崔明府》"] +["城东桃李须臾尽,争似垂杨无限时?","刘禹锡《杨柳枝词九首》"] +["日月行空从地转,蛟龙入海卷潮回。","廖燕《粤王台怀古》"] +["岭上寒多,溪头月冷,北枝瘦、南枝小。","吴文英《天香·蜡梅》"] +["蒹葭渚,不减潇湘深处。","纳兰性德《摸鱼儿·午日雨眺》"] +["翩翩数骑闲猎,深入黑山头。","黄庭坚《水调歌头·落日塞垣路》"] +["记得绿罗裙,处处怜芳草。","贺铸《绿罗裙·东风柳陌长》"] +["舟之前后如朝霞相映,望之不断也。","陈与义《虞美人·扁舟三日秋塘路》"] +["漫向寒炉醉玉瓶,唤君同赏小窗明。","陆游《浣溪沙·和无咎韵》"] +["酸枣垂北郭,寒瓜蔓东篱。","李白《寻鲁城北范居士失道落苍耳中见范置酒摘苍耳作》"] +["玉颜皓齿,深锁三十六宫秋。","黄庭坚《水调歌头·落日塞垣路》"] +["迟迟好景烟花媚,曲渚鸳鸯眠锦翅。","魏承班《木兰花·小芙蓉》"] +["黄鹂啭处谁同听,白菊开时且剩过。","司空图《白菊杂书四首》"] +["意气相倾两相顾,斗酒双鱼表情素。","李白《酬中都小吏携斗酒双鱼于逆旅见赠》"] +["小戎俴收,五楘梁辀。","佚名《小戎》"] +["看他们,得人怜,秦吉了。","辛弃疾《千年调·卮酒向人时》"] +["绿暗红嫣浑可事。","欧阳修《青玉案·一年春事都来几》"] +["漂流百战偶然存,独立千载谁与友?","苏轼《石鼓歌》"] +["感时怀旧独凄然。","苏轼《浣溪沙·菊节》"] +["步从容 立端正 揖深圆 拜恭敬","《弟子规·谨》"] +["伊吕两衰翁,历遍穷通。","王安石《浪淘沙令·伊吕两衰翁》"] +["终远兄弟,谓他人母。","佚名《葛藟》"] +["人有不为也,而后可以有为也。","《孟子·离娄章句下·第八节》"] +["大无信也,不知命也!","佚名《蝃蝀》"] +["楼头客子杪秋后,日落君山元气中。","陈与义《登岳阳楼》"] +["陇头流水,鸣声呜咽。","佚名《陇头歌辞三首》"] +["夕息抱影寐,朝徂衔思往。","陆机《赴洛道中作》"] +["惟有河边雁,秋来南向飞。","庾信《重别周尚书》"] +["呵笔难临帖,敲床且煮茶。","大须《暮雪》"] +["国有贤相良将,民之师表也。","《史记·七十列传·太史公自序》"] +["要破帽多添华发。","辛弃疾《贺新郎·把酒长亭说》"] +["谢娘别后谁能惜,飘泊天涯。","纳兰性德《采桑子·塞上咏雪花》"] +["一摘使瓜好,再摘使瓜稀。","李贤《黄台瓜辞》"] +["念名利,憔悴长萦绊。","柳永《戚氏·晚秋天》"] +["十分斟酒敛芳颜。","张舜民《卖花声·题岳阳楼》"] +["足下金鑮履,手中双莫邪。","张华《轻薄篇》"] +["人生自古谁无死?留取丹心照汗青。","文天祥《过零丁洋》"] +["临皋烟景世间无。","苏轼《浣溪沙·覆块青青麦未苏》"] +["北固山前三面水。","苏轼《蝶恋花·京口得乡书》"] +["能几番游,看花又是明年。","张炎《高阳台·西湖春感》"] +["人生愁恨何能免,销魂独我情何限!","李煜《子夜歌·人生愁恨何能免》"] +["万里悲秋常作客,百年多病独登台。","杜甫《登高》"] +["静言庸违,像恭滔天。","《尚书·虞书·尧典》"] +["独坐幽篁里,弹琴复长啸。","王维《竹里馆》"] +["道由白云尽,春与青溪长。","刘昚虚《阙题》"] +["一任钗横鬓乱,永日熏风。","佚名《玉蝴蝶·为甚夜来添病》"] +["一杯酒,问何似,身后名?","辛弃疾《水调歌头·壬子三山被召陈端仁给事饮饯席上作》"] +["岁华向晚愁思,谁念玉关人老?","蔡挺《喜迁莺·霜天秋晓》"] +["刘郎鬓如此,况桃花颜色。","晁补之《忆少年·别历下》"] +["目不能两视而明,耳不能两听而聪。","《荀子·劝学》"] +["乌菱白芡不论钱,乱系青菰裹绿盘。","苏轼《六月二十七日望湖楼醉书》"] +["但使龙城飞将在,不教胡马度阴山。","王昌龄《出塞二首》"] +["孤云还空山, 众鸟各已归。","李白《春日独酌二首》"] +["鬒发俄成素,丹心已作灰。","宋之问《早发始兴江口至虚氏村作》"] +["虏骑闻之应胆慑,料知短兵不敢接,车师西门伫献捷。","岑参《走马川行奉送出师西征 / 走马川行奉送封大夫出师西征》"] +["楚天千里清秋,水随天去秋无际。","辛弃疾《水龙吟·登建康赏心亭》"] +["岂宜遇攀玩,幸免遭伤毁。","白居易《杏园中枣树》"] +["唯见碧流水,曾无黄石公。","李白《经下邳圯桥怀张子房》"] +["念我独兮,忧心京京。","佚名《正月》"] +["謇吾法夫前修兮,非世俗之所服。","屈原《离骚》"] +["薜荔摇青气,桄榔翳碧苔。","宋之问《早发始兴江口至虚氏村作》"] +["不明察,不能烛私。","《韩非子·孤愤》"] +["凌波路冷秋无际,香云随步起。","周密《绣鸾凤花犯·赋水仙》"] +["勤能补拙,俭以养廉。","《格言联璧·从政类》"] +["尹氏大师,维周之氐;秉国之钧,四方是维。","佚名《节南山》"] +["正字不知温饱未,西风吹泪古藤州。","黄庭坚《病起荆江亭即事》"] +["鉴悬日月,辞富山海。百龄影徂,千载心在。","《文心雕龙·征圣》"] +["别人笑我忒风骚,我笑他人看不穿。","唐寅《桃花庵歌》"] +["何为服黄金、吞白玉?","李贺《苦昼短》"] +["况闻内金盘,尽在卫霍室。","杜甫《自京赴奉先县咏怀五百字 / 自京赴奉先咏怀五百字》"] +["下莞上簟,乃安斯寝。","佚名《斯干》"] +["乍削柳枝聊代札,时窥云影学裁衣。","皎然《山居示灵澈上人》"] +["有缘千里来相会,无缘对面不相逢。","《水浒传·第十四回》"] +["恁风流也称,金屋贮娇慵。","吴文英《高山流水·丁基仲侧室善丝桐赋咏晓达音吕备歌舞之妙》"] +["棠梨叶落胭脂色,荞麦花开白雪香。","王禹偁《村行·马穿山径菊初黄》"] +["韩侯取妻,汾王之甥,蹶父之子。","佚名《韩奕》"] +["锦里烟尘外,江村八九家。","杜甫《为农》"] +["唤取红襟翠袖,莫教泪洒英雄。","纳兰性德《清平乐·弹琴峡题壁》"] +["潜寐黄泉下,千载永不寤。","佚名《驱车上东门》"] +["攀龙附凤势莫当,天下尽化为侯王。","杜甫《洗兵马》"] +["江南几日又天涯,谁与寄相思?","董士锡《木兰花慢·武林归舟中作》"] +["平芜尽处是春山,行人更在春山外。","欧阳修《踏莎行·候馆梅残》"] +["精神四飞扬,如出天地间。","李白《游泰山六首》"] +["相逢一醉是前缘,风雨散、飘然何处?","苏轼《鹊桥仙·七夕》"] +["数里黄沙行客路,不堪回首思秦原。","周朴《春日秦国怀古》"] +["平陵东,松柏桐,不知何人劫义公。","佚名《平陵东》"] +["涛似连山喷雪来!","李白《横江词六首》"] +["数间茅屋闲临水,窄衫短帽垂杨里。","王安石《菩萨蛮·数间茅屋闲临水》"] +["时不可留,众不可逆。","《后汉书·本纪·光武帝纪上》"] +["共粉泪,两簌簌。","苏轼《贺新郎·夏景》"] +["试问江南诸伴侣,谁似我,醉扬州。","苏轼《江城子》"] +["轻妆喜楼边,临镜忘纺绩。","左思《娇女诗》"] +["浊波浩浩东倾,今来古往无终极。","许有壬《水龙吟·过黄河》"] +["子有衣裳,弗曳弗娄。","佚名《山有枢》"] +["含香体素欲倾城,山矾是弟梅是兄。","黄庭坚《王充道送水仙花五十支》"] +["抑若扬兮,美目扬兮。","佚名《猗嗟》"] +["金鹅屏风蜀山梦,鸾裾凤带行烟重。","李贺《洛姝真珠》"] +["一宅无遗宇,肪舟荫门前。","陶渊明《戊申岁六月中遇火》"] +["七月七,回首笑三郎。","佚名《喜春来·七夕》"] +["默然遥相许,欲往心莫遂。","李白《江上望皖公山》"] +["经事还谙事,阅人如阅川。","刘禹锡《酬乐天咏老见示》"] +["贵妃胡旋惑君心,死弃马嵬念更深。","白居易《胡旋女》"] +["爝火燃回春浩浩,洪炉照破夜沉沉。","于谦《咏煤炭》"] +["兹川方悠邈,云沙无前后。","高适《自淇涉黄河途中作十三首》"] +["百年长扰扰,万事悉悠悠。","王绩《赠程处士》"] +["树下即门前,门中露翠钿。","佚名《西洲曲》"] +["自昔有贤才,相逢不相识。","高适《自淇涉黄河途中作十三首》"] +["博罗老仙时出洞,千岁石床啼鬼工。","李贺《罗浮山父与葛篇》"] +["想翠竹、碧梧风采,旧游何处。","张宁《满江红·题碧梧翠竹送李阳春》"] +["出郭门直视,但见丘与坟。","佚名《去者日以疏》"] +["幸有五湖烟浪,一船风月,会须归去老渔樵。","柳永《凤归云·向深秋》"] +["淮南弟称号,刻玺于北方。","曹操《蒿里行》"] +["长号别严亲,日月惨光晶。","李白《古风·其一》"] +["空歌望云月,曲尽长松声。","李白《游南阳清泠泉》"] +["不知我者,谓我士也骄。","佚名《园有桃》"] +["敬天之怒,无敢戏豫。","佚名《板》"] +["扁舟三日秋塘路,平度荷花去。","陈与义《虞美人·扁舟三日秋塘路》"] +["道人庭宇静,苔色连深竹。","柳宗元《晨诣超师院读禅经》"] +["我所思兮在雁门。","张衡《四愁诗》"] +["野棠花落,又匆匆过了,清明时节。","辛弃疾《念奴娇·书东流村壁》"] +["梦里砧声浑未歇,那更乱蛩悲咽。","纳兰性德《清平乐·凄凄切切》"] +["孑孑干旄,在浚之郊。","佚名《干旄》"] +["仁义礼善之于人也,辟之若货财粟米之于家也。","《荀子·大略》"] +["北极朝廷终不改,西山寇盗莫相侵。","杜甫《登楼》"] +["每到春来,惆怅还依旧。","冯延巳《鹊踏枝·谁道闲情抛掷久》"] +["然亦安知所养何哉?","文天祥《正气歌》"] +["同云深处望三关,断肠山又山。","向子諲《阮郎归·绍兴乙卯大雪行鄱阳道中》"] +["批竹初攒耳,桃花未上身。","李贺《马诗二十三首》"] +["归家说与采桑姑。","苏轼《浣溪沙·照日深红暖见鱼》"] +["陌上夜阑,襟袖起凉飙。","柳永《凤归云·向深秋》"] +["亲友若云霄,可望不可攀。","高适《自淇涉黄河途中作十三首》"] +["载见辟王,曰求厥章。","佚名《周颂·载见》"] +["新郎君、成行如画。","柳永《柳初新·东郊向晓星杓亚》"] +["羊肠坂诘屈,车轮为之摧。","曹操《苦寒行》"] +["饿死真吾志,梦中行采薇。","文天祥《南安军》"] +["信言不美,美言不信。","《老子·德经·第八十一章》"] +["野云万里无城郭,雨雪纷纷连大漠。","李颀《古从军行》"] +["迎得云归,还送云别。","李之仪《忆秦娥·用太白韵》"] +["未洗染尘缨,归来芳草平。","李白《冬日归旧山》"] +["人前深意难轻诉。","苏轼《蝶恋花·记得画屏初会遇》"] +["海云迷驿道,江月隐乡楼。","李白《寄淮南友人》"] +["夙夜在公,在公饮酒。","佚名《鲁颂·有駜》"] +["吴中好处是苏州,却为王程得胜游。","杨万里《泊平江百花洲》"] +["萍散漫,絮飘飏,轻盈体态狂。","曾觌《阮郎归·柳阴庭院占风光》"] +["狂风落尽深红色,绿叶成阴子满枝。","杜牧《怅诗》"] +["逼迫迁旧邦,拥主以自疆。","蔡琰《悲愤诗》"] +["羁鸟恋旧林,池鱼思故渊。","陶渊明《归园田居·其一》"] +["词客看花心意苦,坠粉零香,果是谁相误。","康有为《蝶恋花·记得珠帘初卷处》"] +["小头鞵履窄衣裳,青黛点眉眉细长。","白居易《上阳白发人》"] +["想当时,周郎年少,气吞区宇。","戴复古《满江红·赤壁怀古》"] +["僧寺多藏深树里,人家半在夕阳西。","德元《玄墓看梅》"] +["形不正者,德不来;中不精者,心不冶。正形饰德,万物毕得","《管子·心术下》"] +["寄语东阳沽酒市,拼一醉,而今乐事他年泪。","朱服《渔家傲·小雨纤纤风细细》"] +["自惭衰病心神耗,赖有群公识鉴精。","欧阳修《礼部贡院阅进士试》"] +["药医不死病,死病无药医。","《何典·第三回》"] +["王侯第宅皆新主,文武衣冠异昔时。","杜甫《秋兴八首》"] +["约何时再,正春浓酒困,人闲昼永无聊赖。","贺铸《薄幸·淡妆多态》"] +["四座醉清光,为欢古来无。","李白《泛沔州城南郎官湖》"] +["明敕星驰封宝剑,辞君一夜取楼兰。","王昌龄《从军行七首》"] +["天下虽安,忘战必危。","《司马法·仁本》"] +["修禊近,卖饧时。","张炎《鹧鸪天·楼上谁将玉笛吹》"] +["寂寂锁朱门,梦承恩。","纳兰性德《昭君怨·深禁好春谁惜》"] +["金阙晓钟开万户,玉阶仙仗拥千官。","岑参《奉和中书舍人贾至早朝大明宫》"] +["刻薄不赚钱,忠厚不折本。","《醒世恒言·卷三》"] +["叶声寒、飞透窗纱。","邓剡《唐多令·雨过水明霞》"] +["丙午人日,予客长沙别驾之观政堂。","姜夔《一萼红·古城阴》"] +["一掬钓鱼坛上泪,风浩浩,雨冥冥。","元好问《江城子·醉来长袖舞鸡鸣》"] +["明日潮来无定准。","苏轼《渔家傲·送台守江郎中》"] +["将欲歙之,必故张之;将欲弱之,必故强之;将欲废之,必故兴之;将欲取之,必故与之。是谓微明。","《老子·道经·第三十六章》"] +["乱山深处水萦回,可惜一枝如画为谁开?","秦观《虞美人·碧桃天上栽和露》"] +["庄生晓梦迷蝴蝶,望帝春心托杜鹃。","李商隐《锦瑟》"] +["可以仕则仕,可以止则止。","《孟子·公孙丑章句上·第二节》"] +["窗前竹叶,凛凛狂风折。","张淑芳《满路花·冬》"] +["先立无念为宗,无相为体,无住为本。","《六祖坛经·定慧·第一节》"] +["睆彼牵牛,不以服箱。","佚名《大东》"] +["举国繁华委逝川,羽毛飘荡一年年。","吴融《子规》"] +["素手青条上,红妆白日鲜。","李白《子夜吴歌·春歌》"] +["回首旧游浑不见,苍烟一片荒山。","张宏范《临江仙·忆旧》"] +["昊天不佣,降此鞠訩。","佚名《节南山》"] +["玉皇开碧落,银界失黄昏。","毛滂《临江仙·都城元夕》"] +["穆穆文王,于缉熙敬止。","佚名《文王》"] +["衣湿桐阴露冷,采凉花、时赋秋雪。","周密《玉京秋·烟水阔》"] +["五千言、老来受用,肯教造物儿戏?","姜夔《永遇乐·次韵辛克清先生》"] +["人言此是海门关,海眼无涯骇众观。","谢枋得《小孤山》"] +["觥船一棹百分空。","晏殊《喜迁莺·花不尽》"] +["艳骨已成兰麝土,宫墙依旧压层崖。","皮日休《馆娃宫怀古》"] +["一言既出,驷马难追。","《增广贤文·上集》"] +["乡书何处达?归雁洛阳边。","王湾《次北固山下》"] +["蕃马常来射狐兔,汉兵不道传烽燧。","王安石《白沟行》"] +["将军金甲夜不脱,半夜军行戈相拨,风头如刀面如割。","岑参《走马川行奉送出师西征 / 走马川行奉送封大夫出师西征》"] +["楼上不宜秋,秋宜不上楼。","苏轼《菩萨蛮·回文秋闺怨》"] +["慧者心辩而不繁说,多力而不伐功,此以名誉扬天下。","《墨子·02章 修身》"] +["由来万夫勇,挟此生雄风。","李白《结客少年场行》"] +["群公先正,则不我闻。","佚名《云汉》"] +["清瑟怨遥夜,绕弦风雨哀。","韦庄《章台夜思》"] +["恣幕天席地,陶陶尽醉太平,且乐唐虞景化。","柳永《抛球乐·晓来天气浓淡》"] +["戚戚兄弟,莫远具尔。","佚名《行苇》"] +["遥山变色,妆眉淡扫。","柳永《倾杯·冻水消痕》"] +["谓山盖卑,为冈为陵。","佚名《正月》"] +["既见君子,云胡不喜。","佚名《风雨》"] +["我志在删述,垂辉映千春。","李白《古风·大雅久不作》"] +["天明下直明光宫,散入五陵松柏中。","王建《羽林行》"] +["几重庭院,几重帘幕。","黄机《忆秦娥·秋萧索》"] +["万里云沙涨,平原冰霰涩。","王昌龄《从军行二首》"] +["预恐樽中尽,更起为君谋。","杜甫《夏日李公见访》"] +["晚趁寒潮渡江去,满林黄叶雁声多。","王士祯《江上》"] +["跃跃毚兔,遇犬获之。","佚名《巧言》"] +["香阁掩,眉敛,月将沉。","顾夐《诉衷情·永夜抛人何处去》"] +["日之方中,在前上处。","佚名《简兮》"] +["马上离魂衣上泪。各自个、供憔悴。","程垓《酷相思·月挂霜林寒欲坠》"] +["原隰既平,泉流既清。","佚名《黍苗》"] +["念兹轻薄质,无翅强摇空。","李世民《咏兴国寺佛殿前幡》"] +["江上楚歌最哀怨,招魂不独为灵均。","严遂成《乌江项王庙》"] +["春风无限潇湘意,欲采蘋花不自由。","柳宗元《酬曹侍御过象县见寄》"] +["多谢梅花,伴我微吟。","韩疁《高阳台·除夜》"] +["荣华诚足贵,亦复可怜伤。","陶渊明《拟古九首》"] +["空园数日无芳信,恻恻残寒犹未定。","吴翌凤《玉楼春·空园数日无芳信》"] +["所以终日醉,颓然卧前楹。","李白《春日醉起言志》"] +["永夜恹恹欢意少。","李清照《蝶恋花·上巳召亲族》"] +["寝息何时忘,沈忧日盈积。","潘安《悼亡诗三首》"] +["泛泛杨舟,载沉载浮。","佚名《菁菁者莪》"] +["彤管有炜,说怿女美。","佚名《静女》"] +["金规印遥汉,庭浪无纹。","吴文英《夜飞鹊·蔡司户席上南花》"] +["老少同一死,贤愚无复数。","陶渊明《形影神三首》"] +["偶学念奴声调,有时高遏行云。","晏殊《山亭柳·赠歌者》"] +["百年歌自苦,未见有知音。","杜甫《南征》"] +["一节见则百节知矣。","《说苑·尊贤》"] +["渐暗竹敲凉,疏萤照晚,两地魂消。","周邦彦《忆旧游·记愁横浅黛》"] +["别来几春未还家,玉窗五见樱桃花。","李白《久别离》"] +["却回山寺路,闻打暮天钟。","贾岛《雪晴晚望》"] +["山中也有千年树,世上难逢百岁人。","《增广贤文·上集》"] +["怎得银笺,殷勤与说年华。","王沂孙《高阳台·和周草窗寄越中诸友韵》"] +["霸业成空,遗恨无穷。","查德卿《蟾宫曲·怀古》"] +["无事经年别远公,帝城钟晓忆西峰。","李商隐《忆住一师》"] +["玉墀争采,玉钗争插,至正年间。","纳兰性德《眼儿媚·咏红姑娘》"] +["莺歌闻太液,凤吹绕瀛洲。","李白《宫中行乐词八首》"] +["爱子游燕赵,高堂有老亲。","王维《观别者》"] +["岂弟君子,四方为纲。","佚名《卷阿》"] +["秋向晚,一枝何事,向我依然。","苏轼《雨中花·夜行船》"] +["君去京东豪杰喜,想投戈、下拜真吾父。","刘克庄《贺新郎·送陈真州子华》"] +["且将换酒与君醉,醉归托宿吴专诸。","李白《醉后赠从甥高镇》"] +["扬眉转袖若雪飞,倾城独立世所稀。","李白《白纻辞三首》"] +["烟蓑散响惊诗思,还被乱鸥飞去,秀句难续。","史达祖《八归·秋江带雨》"] +["从教分付,绿窗红泪,早雁初莺。","纳兰性德《青衫湿·悼亡》"] +["极目平沙千里,惟见雕弓白羽,铁面骏骅骝。","黄庭坚《水调歌头·落日塞垣路》"] +["入春才七日,离家已二年。","薛道衡《人日思归》"] +["茨菰叶烂别西湾,莲子花开犹未还。","张潮《江南行》"] +["於赫汤孙!穆穆厥声。","佚名《商颂·那》"] +["落日绣帘卷,亭下水连空。","苏轼《水调歌头·黄州快哉亭赠张偓佺》"] +["胡姬年十五,春日独当垆。","辛延年《羽林郎》"] +["携扙来追柳外凉,画桥南畔倚胡床。","秦观《纳凉》"] +["平平左右,亦是率从。","佚名《采菽》"] +["林间戏蝶帘间燕,各自双双。","冯延巳《采桑子·花前失却游春侣》"] +["无草不死,无木不萎。","佚名《小雅·谷风》"] +["辘轳咿哑转鸣玉,惊起芙蓉睡新足。","李贺《美人梳头歌》"] +["满招损,谦受益","《尚书·虞书·大禹谟》"] +["何斯违斯,莫敢遑息?","佚名《殷其雷》"] +["侧身送落日,引手攀飞星。","苏轼《真兴寺阁》"] +["世情薄,人情恶,雨送黄昏花易落。","唐婉《钗头凤·世情薄》"] +["昨夜斗回北,今朝岁起东。","孟浩然《田家元日》"] +["望极春愁,黯黯生天际。","柳永《蝶恋花·伫倚危楼风细细》"] +["禁里疏钟官舍晚,省中啼鸟吏人稀。","王维《酬郭给事 / 赠郭给事》"] +["泛彼柏舟,在彼河侧。","佚名《鄘风·柏舟》"] +["靖康耻,犹未雪。","岳飞《满江红·写怀》"] +["烟霏霏,雪霏霏。","吴淑姬《长相思令·烟霏霏》"] +["兀兀寄形群动内,陶陶任性一生间。","白居易《琴茶》"] +["抽弦促柱听秦筝,无限秦人悲怨声。","柳中庸《听筝》"] +["金波银汉,潋滟无际。","柳永《佳人醉·暮景萧萧雨霁》"] +["以御宾客,且以酌醴。","佚名《吉日》"] +["春风举国裁宫锦,半作障泥半作帆。","李商隐《隋宫 / 隋堤》"] +["东阳素足女,会稽素舸郎。","李白《越女词五首》"] +["子惠思我,褰裳涉洧。","佚名《褰裳》"] +["漫惹炉烟双袖紫,空将酒晕一衫青。","纳兰性德《浣溪沙·伏雨朝寒愁不胜》"] +["苍苍竹林寺,杳杳钟声晚。","刘长卿《送灵澈上人》"] +["激水之疾,至于漂石者,势也;鸷鸟之疾,至于毁折者,节也。","《孙子兵法·兵势篇》"] +["小楼一夜听春雨,深巷明朝卖杏花。","陆游《临安春雨初霁》"] +["殊类非所安,虽贵非所荣。","石崇《王明君》"] +["志士不忘在沟壑,勇士不忘丧其元。","《孟子·滕文公章句下·第一节》"] +["物壮则老,是谓不道,不道早已。","《老子·道经·第三十章》"] +["五更风雨断遥岑。","陈子龙《唐多令·寒食》"] +["君不见周南歌汉广,天教夫子休乔木。","苏轼《满江红·忧喜相寻》"] +["满山残雪对虚堂,想似当年辋口庄。","朱熹《次韵雪后书事二首》"] +["不可以身相得见如来。","《金刚经·如理实见》"] +["岂意彭城山下,同泛清河古汴,船上载凉州。","苏辙《水调歌头·徐州中秋》"] +["燕塞雪,片片大如拳。","华清淑《望江南·燕塞雪》"] +["依丛适自憩,缘涧还复去。","韦应物《东郊》"] +["身如西瀼渡头云,愁抵瞿塘关上草。","陆游《木兰花·立春日作》"] +["我觏之子,乘其四骆。","佚名《裳裳者华》"] +["玉门关城迥且孤,黄沙万里白草枯。","岑参《玉门关盖将军歌》"] +["荒台麋鹿争新草,空苑岛凫占浅莎。","许浑《姑苏怀古》"] +["乐只君子,福履将之。","佚名《樛木》"] +["王孙莫学多情客,自古多情损少年。","温庭筠《和友人伤歌姬》"] +["徒干五诸侯,不致百金产。","李白《送王屋山人魏万还王屋·并序》"] +["於乎哀哉!维今之人,不尚有旧!","佚名《大雅·召旻》"] +["明君不释法度, 故机诈不肆其巧。","《抱朴子·外篇·用刑》"] +["含悲想旧国,泣下谁能挥。","李白《秋夕旅怀》"] +["臣强则死,念妄则亡。","《罗织经·事上卷第二》"] +["寒食时看郭外春,野人无处不伤神。","云表《寒食诗》"] +["钓者故池平,神台尘宇覆。","李百药《郢城怀古》"] +["顾谓戚夫人,彼翁羽翼成。","李白《山人劝酒》"] +["何以致契阔?绕腕双跳脱。","繁钦《定情诗》"] +["汉家三十六将军,东方雷动横阵云。","柳宗元《古东门行》"] +["长跪读素书,书中竟何如。","佚名《饮马长城窟行》"] +["平生半世看墨本,摩挲石刻鬓成丝。","黄庭坚《书摩崖碑后》"] +["宣室求贤访逐臣,贾生才调更无伦。","李商隐《贾生》"] +["后来者,无二公之操,百炼之钢。","文天祥《沁园春·题潮阳张许二公庙》"] +["遂命酒,使快弹数曲。","白居易《琵琶行 / 琵琶引》"] +["玉京曾忆昔繁华。","赵佶《眼儿媚·玉京曾忆昔繁华》"] +["听言则答,谮言则退。","佚名《雨无正》"] +["非无脚下浮云闹,来不相知去不留。","郑燮《题画兰》"] +["吁嗟逢橡媪,不觉泪沾裳。","皮日休《橡媪叹》"] +["挥羽扇,整纶巾,少年鞍马尘。","辛弃疾《阮郎归·耒阳道中为张处父推官赋》"] +["丝丝香篆浓于雾,织就绿阴红雨。","高鹗《青玉案·丝丝香篆浓于雾》"] +["勿使燕然上,惟留汉将功。","陈子昂《送魏大从军》"] +["举贤任能,不时日而事利。","《尉缭子·战威》"] +["君子处小人,必平其气以待之,不可稍形激切也。","《围炉夜话·第三三则》"] +["日月光华,旦复旦兮。","佚名《卿云歌》"] +["木从绳则正,后从谏则圣。","《尚书·商书·说命上》"] +["珠中有隐字,欲辨不成书。","杜甫《客从》"] +["闲读道书慵未起,水晶帘下看梳头。","元稹《离思五首》"] +["蓬莱宫阙对南山,承露金茎霄汉间。","杜甫《秋兴八首》"] +["转头向户里,渐见愁煎迫。","佚名《孔雀东南飞 / 古诗为焦仲卿妻作》"] +["诗罢闻吴咏,扁舟意不忘。","杜甫《夜宴左氏庄》"] +["辞粟卧首阳,屡空饥颜回。","李白《月下独酌四首》"] +["圣代复元古,垂衣贵清真。","李白《古风·大雅久不作》"] +["莽洋洋而无极兮,忽翱翔之焉薄?","宋玉《九辩》"] +["倦游欢意少,俯仰悲今古。","姜夔《玲珑四犯·越中岁暮闻箫鼓感怀》"] +["荒裔一戎衣,灵台凯歌入。","李世民《饮马长城窟行》"] +["自责之外,无胜人之术。自强之外,无上人之术。","《格言联璧·持躬类》"] +["雨色风吹去,南行拂楚王。","李白《宿巫山下》"] +["试灯无意思,踏雪没心情。","李清照《临江仙·庭院深深深几许》"] +["骏马似风飙,鸣鞭出渭桥。","李白《塞下曲六首》"] +["他日还相访,乘桥蹑彩虹。","李白《送温处士归黄山白鹅峰旧居》"] +["倬彼甫田,岁取十千。","佚名《小雅·甫田》"] +["柴门闻犬吠,风雪夜归人。","刘长卿《逢雪宿芙蓉山主人》"] +["春愁凝思结眉心,绿绮懒调红锦荐。","顾敻《玉楼春·拂水双飞来去燕》"] +["芳洲拾翠暮忘归,秀野踏青来不定。","张先《木兰花·乙卯吴兴寒食》"] +["潦倒不通世务,愚顽怕读文章。","《红楼梦·第三回》"] +["颜公变法出新意,细筋入骨如秋鹰。","苏轼《孙莘老求墨妙亭诗》"] +["本图相与偕,中更不克俱。","曹植《赠白马王彪·并序》"] +["何以道殷勤?约指一双银。","繁钦《定情诗》"] +["山有栲,隰有杻。","佚名《山有枢》"] +["曲终人醉。多似浔阳江上泪。","朱敦儒《减字木兰花·刘郎已老》"] +["数州消息断,愁坐正书空。","杜甫《对雪》"] +["借问大将谁?恐是霍嫖姚。","杜甫《后出塞五首》"] +["传有沙场千万骑,昨日边庭羽书至。","高适《送浑将军出塞》"] +["便归来,平生万事,那堪回首!","顾贞观《金缕曲二首》"] +["暗水流花径,春星带草堂。","杜甫《夜宴左氏庄》"] +["铸出团团如栲栳。","苏轼《减字木兰花·以大琉璃杯劝王仲翁》"] +["今夜闻君琵琶语,如听仙乐耳暂明。","白居易《琵琶行 / 琵琶引》"] +["返景入深林,复照青苔上。","王维《鹿柴》"] +["言不中法者,不听也;行不中法者,不高也;事不中法者,不为也。","《商君书·君臣》"] +["南窗白日羲皇上,未害渊明是晋人。","元好问《论诗三十首·其四》"] +["遍地关山行不得,为谁辛苦尽情啼?","尤侗《闻鹧鸪》"] +["多君骋逸藻,掩映当时人。","李白《陈情赠友人》"] +["重耳任五贤,小白相射钩。","刘琨《重赠卢谌》"] +["虎拜稽首,对扬王休。","佚名《大雅·江汉》"] +["雨歇梧桐泪乍收,遣怀翻自忆从头。","纳兰性德《浣溪沙·雨歇梧桐泪乍收》"] +["与君剩采江山景,裁取新诗入帝乡。","杜荀鹤《冬末同友人泛潇湘》"] +["谁侜予美?心焉惕惕。","佚名《防有鹊巢》"] +["白白与红红,别是东风情味。","严蕊《如梦令·道是梨花不是》"] +["不作无益害有益,功乃成;不贵异物贱用物,民乃足。","《尚书·周书·旅獒》"] +["欢声震地,惊退万人争战气。","黄裳《减字木兰花·竞渡》"] +["世上岂无千里马,人中难得九方皋。","黄庭坚《过平舆怀李子先时在并州》"] +["漫惹炉烟双袖紫,空将酒晕一衫青。","纳兰性德《浣溪沙·伏雨朝寒愁不胜》"] +["骑虎不敢下,攀龙忽堕天。","李白《留别广陵诸公 / 留别邯郸故人》"] +["劝我早还家,绿窗人似花。","韦庄《菩萨蛮·红楼别夜堪惆怅》"] +["日落山水静,为君起松声。","王勃《咏风》"] +["慎厥身,修思永。","《尚书·虞书·皋陶谟》"] +["半壁见海日,空中闻天鸡。","李白《梦游天姥吟留别 / 别东鲁诸公》"] +["永弃却、烟花伴侣。","柳永《迷仙引·才过笄年》"] +["记取江湖泊船处,卧闻新雁落寒汀。","陆游《夜泊水村》"] +["牵牛织女,莫是离中。","李清照《行香子·七夕》"] +["顾我薄才无可用。","欧阳修《渔家傲·与赵康靖公》"] +["乘彩舫,过莲塘,棹歌惊起睡鸳鸯。","李珣《南乡子·乘彩舫》"] +["曰黄昏以为期兮,羌中道而改路!","屈原《离骚》"] +["白鹤在何处,尝试与偕来。","辛弃疾《水调歌头·盟鸥》"] +["数声啼鸟,也学相思调。","姜夔《点绛唇·金谷人归》"] +["此情已自成追忆,零落鸳鸯。","纳兰性德《采桑子·谢家庭院残更立》"] +["比似茜裙初染、一般同。","刘铉《乌夜啼·石榴》"] +["五松何清幽,胜境美沃洲。","李白《与南陵常赞府游五松山》"] +["终远兄弟,谓他人父。","佚名《葛藟》"] +["辞之辑矣,民之洽矣。","佚名《板》"] +["割得天孙锦一机。","向子諲《减字木兰花·斜红叠翠》"] +["洛阳亲友如相问,一片冰心在玉壶。","王昌龄《芙蓉楼送辛渐》"] +["听得上方相对话,星辰莫阂五峰巅。","常慧《云居山咏二首》"] +["预恐狂夫折,迎牵逸客迷。","温庭筠《原隰荑绿柳》"] +["典尽客衣三尺雪,炼精诗句一头霜。","王建《维扬冬末寄幕中二从事》"] +["二帝巡游俱未回,五陵松柏使人哀。","李白《永王东巡歌十一首》"] +["强欲登高去,无人送酒来。","岑参《行军九日思长安故园》"] +["猿啸千溪合,松风五月寒。","李白《见京兆韦参军量移东阳二首》"] +["此乐殊未知,吾归当教汝。","张孝祥《湖湘以竹车激水粳稻如云书此能仁院壁》"] +["借问此何时?春风语流莺。","李白《春日醉起言志》"] +["划然长啸,草木震动,山鸣谷应,风起水涌。","苏轼《后赤壁赋》"] +["游人杜陵北,送客汉川东。","尹式《别宋常侍》"] +["归梦寄吴樯。水驿江程去路长。","陆游《南乡子·归梦寄吴樯》"] +["蚕丛及鱼凫,开国何茫然!","李白《蜀道难》"] +["犹有渔人数家住,不成村落夕阳边。","许棠《洞庭阻风》"] +["冷雨青灯读书舍,怕离别又早离别。","姚燧《普天乐·浙江秋》"] +["检与神方教驻景,收将凤纸写相思。","李商隐《碧城三首》"] +["三吴邦伯多顾盼,四海雄侠皆相推。","李白《猛虎行》"] +["飘风不可以调宫商,巧妇不可以主中馈,词章之士不可以治国家。","《格言联璧·学问类》"] +["楚台风,庾楼月,宛如昨。","王安石《千秋岁引·秋景》"] +["撩乱发青条,春风来几日。","司空曙《新柳》"] +["黄昏鼓角似边州,三十年前上此楼。","李益《上汝州郡楼》"] +["交交桑扈,有莺其羽。","佚名《桑扈》"] +["出耒在明晨,山寒易霜霰。","王守仁《谪居粮绝请学于农将田南山咏言寄怀》"] +["积雾杳难极,沧波浩无穷。","柳宗元《初秋夜坐赠吴武陵》"] +["两两轻舠飞画楫,竞夺锦标霞烂。","柳永《破阵乐·露花倒影》"] +["神之斿,过天门,车千乘,敦昆仑。","刘彻《华晔晔》"] +["红酥手,黄縢酒,满城春色宫墙柳。","陆游《钗头凤·红酥手》"] +["图南未可料,变化有鲲鹏。","杜甫《泊岳阳城下 / 泊岳阳楼下》"] +["曾孙之稼,如茨如梁。","佚名《小雅·甫田》"] +["人何在?柳柔摇不定,草短绿应难。","张惠言《风流子·出关见桃花》"] +["是以陷邻境,此州独见全。","元结《贼退示官吏》"] +["怕春波、载愁不去,恁生见得?","麦孟华《解连环·酬任公用梦窗留别石帚韵》"] +["和平处事,勿矫俗为高;正直居心,勿设机以为智。","《围炉夜话·第六五则》"] +["彼月而食,则维其常;此日而食,于何不臧。","佚名《十月之交》"] +["当杯已入手,歌妓莫停声。","孟浩然《春中喜王九相寻 / 晚春》"] +["报扫荡、狐嗥兔舞。","吴潜《海棠春·已未清明对海棠有赋》"] +["更倾寒食泪,欲涨冶城潮。","王安石《壬辰寒食》"] +["天实为之,谓之何哉!","佚名《北门》"] +["俱邀侠客芙蓉剑,共宿娼家桃李蹊。","卢照邻《长安古意》"] +["相见初经田窦家,侯门歌舞出如花。","吴伟业《圆圆曲》"] +["山林乍开乍合,曾不知日月明。","佚名《上陵》"] +["悲歌击筑,凭高酹酒,此兴悠哉。","陆游《秋波媚·七月十六日晚登高兴亭望长安南山》"] +["芳根閟颜色,徂岁为谁荣。","柳宗元《新植海石榴》"] +["闲阶有鸟迹,禅室无人开。","李白《寻山僧不遇作》"] +["永言配命,成王之孚。","佚名《下武》"] +["石梁横青天,侧足履半月。","李白《送王屋山人魏万还王屋·并序》"] +["阶衔免得带兵农,嬉到昏钟,睡到斋钟。","刘克庄《一剪梅·袁州解印》"] +["亭皋木叶下,原隰菊花黄。","蔡伸《水调歌头·亭皋木叶下》"] +["洞里愁空结,人间信莫寻。","鹿虔扆《女冠子·凤楼琪树》"] +["云尽月如练,水凉风似秋。","欧阳詹《荆南夏夜水楼怀昭丘直上人云梦李莘》"] +["遗台何处是黄金?","王鹏运《浣溪沙·题丁兵备丈画马》"] +["排空驭气奔如电,升天入地求之遍。","白居易《长恨歌》"] +["高声索果栗,欲起时被肘。","杜甫《遭田父泥饮美严中丞》"] +["山吐月千仞,残夜水明楼。","王以宁《水调歌头·呈汉阳使君》"] +["但教心似金钿坚,天上人间会相见。","白居易《长恨歌》"] +["谁道东阳都瘦损,凝然点漆精神。","苏轼《临江仙·赠王友道》"] +["南风之时兮,可以阜吾民之财兮。","佚名《南风歌》"] +["念唯有、夜来皓月,照伊自睡。","姜夔《解连环·玉鞭重倚》"] +["人情送匹马,买卖不饶针!","《增广贤文·下集》"] +["绦镟光堪摘,轩楹势可呼。","杜甫《画鹰》"] +["万里风烟,一溪霜月,未怕欺他得。","辛弃疾《念奴娇·梅》"] +["万壑千岩楼外雪,琼作树,玉为栏。","辛弃疾《江神子·送元济之归豫章》"] +["其风肆好,以赠申伯。","佚名《崧高》"] +["虽非甲胄士,畴昔览《穰苴》。","左思《咏史八首·其一》"] +["奢侈足以败家;悭吝亦足以败家。","《围炉夜话·第五九则》"] +["别后相思人似月,云间水上到层城。","李冶《明月夜留别》"] +["拟把匣中长剑,换取扁舟一叶,归去老渔蓑。","佚名《水调歌头·平生太湖上》"] +["金膏灭明光,水碧辍流温。","谢灵运《入彭蠡湖口》"] +["复楚情何极,亡秦气未平。","夏完淳《即事》"] +["运巧思、穿针楼上女,抬粉面、云鬟相亚。","柳永《二郎神·炎光谢》"] +["奈何值崩湍,荡析临路垂。","柳宗元《掩役夫张进骸》"] +["二年遍历楚山川。","辛弃疾《鹧鸪天·离豫章别司马汉章大监》"] +["槐叶初匀日气凉,葱葱鼠耳翠成双。","范成大《夏日田园杂兴》"] +["不鸣则已,一鸣惊人。","司马迁《滑稽列传》"] +["小令尊前见玉箫。","晏几道《鹧鸪天·小令尊前见玉箫》"] +["春风得意马蹄疾,一日看尽长安花。","孟郊《登科后》"] +["蚕老客未归,白田已缫丝。","李白《白田马上闻莺》"] +["想子胥今夜见嫦娥,沉冤雪。","史达祖《满江红·中秋夜潮》"] +["瘦叶几经雪,淡花应少春。","于武陵《赠卖松人》"] +["岸雨过城头,黄鹂上戍楼。","岑参《武威春暮闻宇文判官西使还已到晋昌》"] +["交鼓吹兮弹丝,觞清泠之池阁。","李白《鸣皋歌送岑徵君》"] +["梦断刀头,书开虿尾,别有相思随定。","史达祖《齐天乐·中秋宿真定驿》"] +["村寒白屋念娇婴,古台石磴悬肠草。","李贺《老夫采玉歌》"] +["东风何时至?已绿湖上山。","丘为《题农父庐舍》"] +["北斗戾,南山摧。","李白《上云乐》"] +["少年为诗,意浮艳,多陷轻薄,晚节忽变常体,风骨凛然。","《唐才子传·卷一》"] +["秋坐金张馆,繁阴昼不开。","李白《玉真公主别馆苦雨赠卫尉张卿二首》"] +["志欲图篡弑,先害诸贤良。","蔡琰《悲愤诗》"] +["碑高三丈字如斗,负以灵鳌蟠以螭。","李商隐《韩碑》"] +["洞房温且清,寒暑不能干。","白居易《伤大宅》"] +["一时劝人以口,百世劝人以书。","《了凡四训·积善之方》"] +["黄金装战马,白羽集神兵。","陈子昂《和陆明府赠将军重出塞》"] +["把破帽、年年拈出。","刘克庄《贺新郎·九日》"] +["申伯之功,召伯是营。","佚名《崧高》"] +["露湿晴花春殿香,月明歌吹在昭阳。","李益《宫怨》"] +["岂无服马?不如叔也。","佚名《叔于田》"] +["杨柳千条送马蹄,北来征雁旧南飞。","纳兰性德《浣溪沙·杨柳千条送马蹄》"] +["新诗如玉屑,出语便清警。","苏轼《送参寥师》"] +["勍敌猛,戎马殷,横阵亘野若屯云。","何承天《战城南》"] +["清尘凝、层层刻碎冰叶。","吴文英《凄凉犯·重台水仙》"] +["伊昔临广武,连兵决雌雄。","李白《登广武古战场怀古》"] +["讯予不顾,颠倒思予。","佚名《墓门》"] +["谁怜不得意,长剑独归来。","高适《自蓟北归》"] +["使君宏放,谈笑洗尽古今愁。","陆游《水调歌头·多景楼》"] +["一门即无主,焉用徒生为!","柳宗元《哭连州凌员外司马》"] +["风流不把花为主。","张鎡《菩萨蛮·芭蕉》"] +["苔枝缀玉,有翠禽小小,枝上同宿。","姜夔《疏影·苔枝缀玉》"] +["吴儿生长狎涛渊,冒利轻生不自怜。","苏轼《八月十五日看潮五绝》"] +["乐哉新相知,忧来生别离。","佚名《艳歌何尝行》"] +["陟彼崔嵬,我马虺隤。","佚名《卷耳》"] +["愿持此邀君,一饮空缸。","苏轼《满庭芳·三十三年》"] +["高余冠之岌岌兮,长余佩之陆离。","屈原《离骚》"] +["昭阳桃李月,罗绮自相亲。","李白《宫中行乐词八首》"] +["匪载匪来,忧心孔疚。","佚名《小雅·杕杜》"] +["青天无云月如烛,露泣梨花白如玉。","无名氏《杂诗》"] +["宰嚭亡吴国,西施陷恶名。","崔道融《西施滩》"] +["佛时仔肩,示我显德行。","佚名《周颂·敬之》"] +["下车上马太匆匆,来是春风,去是秋风。","刘克庄《一剪梅·袁州解印》"] +["落木萧萧,琉璃叶下琼葩吐。","王十朋《点绛唇·素香丁香》"] +["恐是仙家好别离,故教迢递作佳期。","李商隐《辛未七夕》"] +["趁残鸦、飞过苍茫。","吴文英《夜合花·自鹤江入京泊葑门外有感》"] +["无云世界秋三五,共看蟾盘上海涯。","曹松《中秋对月》"] +["余固知謇謇之为患兮,忍而不能舍也。","屈原《离骚》"] +["月冷龙沙,尘清虎落,今年汉酺初赐。","姜夔《翠楼吟·淳熙丙午冬》"] +["春漏促,金烬暗挑残烛。","韦庄《谒金门·春漏促》"] +["皇情眷亿兆,割念俯怀柔。","郑愔《送金城公主适西蕃应制》"] +["任是春风不管,也曾先识东皇。","陆游《朝中措·梅》"] +["隔水高楼,望断双鱼信。","赵令畤《蝶恋花·卷絮风头寒欲尽》"] +["枕上十年事,江南二老忧,都到心头。","徐再思《水仙子·夜雨》"] +["顷刻光阴都过了,如今绿暗红英少。","欧阳修《蝶恋花·尝爱西湖春色早》"] +["秋晚佳晨重物华,高台复帐驻鸣笳。","宋祁《九日置酒》"] +["雪后燕瑶池,人间第一枝。","赵令《菩萨蛮·春风试手先梅蕊》"] +["有功翰墨乃如此,何恨远别音书少。","黄庭坚《送王郎》"] +["忽忆周天子,驱车上玉山。","李贺《马诗二十三首》"] +["新烟禁柳,想如今、绿到西湖。","张炎《渡江云·山阴久客一再逢春回忆西杭渺然愁思》"] +["正过雨、荆桃如菽。","蒋捷《贺新郎·梦冷黄金屋》"] +["杏园风细,桃花浪暖,竞喜羽迁鳞化。","柳永《柳初新·东郊向晓星杓亚》"] +["未必人间无好汉,谁与宽些尺度?","刘克庄《贺新郎·国脉微如缕》"] +["永结无情游,相期邈云汉。","李白《月下独酌四首·其一》"] +["殊方日落玄猿哭,旧国霜前白雁来。","杜甫《九日五首·其一》"] +["干羽方怀远,静烽燧,且休兵。","张孝祥《六州歌头·长淮望断》"] +["双燕双飞绕画梁,罗帷翠被郁金香。","卢照邻《长安古意》"] +["君为张掖近酒泉,我窜三色九千里。","李白《江夏赠韦南陵冰》"] +["一日不思量,也攒眉千度。","柳永《昼夜乐·洞房记得初相遇》"] +["故父母、学、君三者,莫可以为治法。","《墨子·04章 法仪》"] +["帝王之道,以宽仁为大。","《周书·本纪·卷四》"] +["春山茂,春日明。","鲍照《春日行》"] +["回风一萧瑟,林影久参差。","柳宗元《南涧中题》"] +["愁望春归,春到更无绪。","文廷式《祝英台近·剪鲛绡》"] +["家林秀桔霜老,笑分得、蟾边桂种。","吴文英《宴清都·送马林屋赴南宫分韵得动字》"] +["雨睛气爽,伫立江楼望处。","柳永《诉衷情近·雨晴气爽》"] +["乌丝阑纸娇红篆,历历春星。","纳兰性德《红窗月·燕归花谢》"] +["上山采蘼芜,下山逢故夫。","佚名《上山采蘼芜》"] +["追念照水然犀,男儿当似此,英碓豪杰。","吴渊《念奴娇·我来牛渚》"] +["可恨相逢能几日,不知重会是何年。","苏轼《浣溪沙·重九旧韵》"] +["炎威天气日偏长,汗湿轻罗倚画窗。","智生《夏词》"] +["斜日低山片月高,睡余行药绕江郊。","范成大《冬日田园杂兴》"] +["若使学萧曹,功名当不朽。","高适《自淇涉黄河途中作十三首》"] +["诚者,天之道也;思诚者,人之道也。","《孟子·离娄章句上·第十二节》"] +["犹带彤霞晓露痕。","李清照《减字木兰花·卖花担上》"] +["明河隔黄姑,碧落杳飞鹜。","洪咨夔《送监丞家同年守简池三十韵》"] +["香亦竟不灭,人亦竟不来。","李白《长相思三首》"] +["能令暂开霁,过是吾无求。","韩愈《洞庭湖阻风赠张十一署·时自阳山徙掾江陵》"] +["虽然舞袖何曾舞,常对春风裛泪痕。","无名氏《杂诗》"] +["业无高卑志当坚,男儿有求安得闲。","张耒《示秬秸》"] +["绣屋秦筝,傍海棠偏爱,夜深开宴。","吴文英《三姝媚·过都城旧居有感》"] +["得意犹堪夸世俗,诏黄新湿字如鸦。","苏轼《和董传留别》"] +["先帝侍女八千人,公孙剑器初第一。","杜甫《观公孙大娘弟子舞剑器行》"] +["吾令鸩为媒兮,鸩告余以不好。","屈原《离骚》"] +["望处雨收云断,凭阑悄悄,目送秋光。","柳永《玉蝴蝶·望处雨收云断》"] +["微红夕如灭,短焰复相连。","温庭筠《烧歌》"] +["行舟逗远树,度鸟息危樯。","阴铿《渡青草湖》"] +["残云归太华,疏雨过中条。","许浑《秋日赴阙题潼关驿楼 / 行次潼关逢魏扶东归》"] +["春卿拾材白日下,掷置黄金解龙马。","李贺《送沈亚之歌·并序》"] +["学母无不为,晓妆随手抹。","杜甫《北征》"] +["深林人不知,明月来相照。","王维《竹里馆》"] +["情怀正恶,更衰草寒烟淡薄。","姜夔《凄凉犯·绿杨巷陌秋风起》"] +["杖藜叹世者谁子,泣血迸空回白头。","杜甫《白帝城最高楼》"] +["寒水依痕,春意渐回,沙际烟阔。","张元干《石州慢·寒水依痕》"] +["教民礼顺,莫善于悌。","《孝经·广要道》"] +["叱咤四海动,洪涛为簸扬。","李白《上云乐》"] +["月出青山送行子,四边苦竹秋声起。","李白《和卢侍御通塘曲》"] +["际夜转西壑,隔山望南斗。","綦毋潜《春泛若耶溪》"] +["断霞遮,夕阳斜,山腰闪出闲亭榭。","薛昂夫《西湖杂咏·秋》"] +["落叶聚还散,寒鸦栖复惊。","李白《三五七言 / 秋风词》"] +["佳人舞点金钗溜,酒恶时拈花蕊嗅。","李煜《浣溪沙·红日已高三丈透》"] +["不到东山向一年,归来才及种春田。","王维《辋川别业》"] +["今日任公子,沧浪罢钓竿。","李白《金陵望汉江》"] +["一朝去金马,飘落成飞蓬。","李白《东武吟》"] +["无已大康,职思其外。","佚名《蟋蟀》"] +["忾我寤叹,念彼京周。","佚名《下泉》"] +["山光物态弄春晖,莫为轻阴便拟归。","张旭《山中留客》"] +["婉兮娈兮,季女斯饥。","佚名《候人》"] +["凡与敌战,若我胜彼负,不可骄惰,当日夜严备以待之。","《百战奇略·第六卷·胜战》"] +["乘兴杳然迷出处,对君疑是泛虚舟。","杜甫《题张氏隐居二首》"] +["上有青冥之长天,下有渌水之波澜。","李白《长相思·其一》"] +["己所不欲,勿施于人。","《论语·颜渊篇》"] +["感物伤我怀,抚心长太息。","曹植《赠白马王彪·并序》"] +["竹影和诗瘦,梅花入梦香。","王庭筠《绝句·竹影和诗瘦》"] +["知屋漏者在宇下,知政失者在草野。","《论衡·卷二十八·书解篇》"] +["一笑从知春有意,篱边,三两余花向我妍。","陈洵《南乡子·己巳三月自郡城归乡过区菶吾西园话旧》"] +["未必逢矰缴,孤飞自可疑。","崔涂《孤雁二首·其二》"] +["斜阳外,春雨足,风吹皱一池寒玉。","李致远《落梅风·斜阳外》"] +["知君惯度祁连城,岂能愁见轮台月。","岑参《送李副使赴碛西官军》"] +["民之无良,相怨一方。","佚名《角弓》"] +["沉吟久,怕君恩未许,此意徘徊。","辛弃疾《沁园春·带湖新居将成》"] +["高柳垂阴,老鱼吹浪,留我花间住。","姜夔《念奴娇·闹红一舸》"] +["凤不至空台上,燕飞来百姓家,恨满天涯。","张可久《湘妃怨·次韵金陵怀古》"] +["闻道欲来相问讯,西楼望月几回圆。","韦应物《寄李儋元锡》"] +["迷人口说,智者心行。","《六祖坛经·般若·第一节》"] +["问嫦娥、孤令有愁无?应华发。","辛弃疾《满江红·中秋寄远》"] +["忽蒙白日回景光,直上青云生羽翼。","李白《驾去温泉后赠杨山人》"] +["想边鸿孤唳,砌蛩私语。","王沂孙《扫花游·秋声》"] +["移居要就,窗中远岫,舍后长松。","元好问《人月圆·重冈已隔红尘断》"] +["遥瞻四五峰,壁立皆奇绝。","德隐《新秋晚眺》"] +["弄儿床前戏,看妇机中织。","鲍照《拟行路难·其六》"] +["宛洛望不见,秋霖晦平陆。","王维《宿郑州》"] +["搓得鹅儿黄欲就,天气清明时候。","赵令畤《清平乐·春风依旧》"] +["休说鲈鱼堪脍,尽西风,季鹰归未?","辛弃疾《水龙吟·登建康赏心亭》"] +["撩乱发青条,春风来几日。","司空曙《新柳》"] +["东有启明,西有长庚。","佚名《大东》"] +["动求目方智,默守标静仁。","支遁《咏利城山居》"] +["嫣然摇动,冷香飞上诗句。","姜夔《念奴娇·闹红一舸》"] +["赵客缦胡缨,吴钩霜雪明。","李白《侠客行》"] +["依依似与骚人语。","贺铸《芳心苦·杨柳回塘》"] +["新书报我添憔悴。","苏轼《蝶恋花·昨夜秋风来万里》"] +["北眺崿嶂奇,倾崖向东摧。","李白《游泰山六首》"] +["将之器,其用大小不同。","《将苑·卷一·将器》"] +["无信人之言,人实不信。","佚名《郑风·扬之水》"] +["四边伐鼓雪海涌,三军大呼阴山动。","岑参《轮台歌奉送封大夫出师西征》"] +["不忍覆馀觞,临风泪数行。","纳兰性德《菩萨蛮·催花未歇花奴鼓》"] +["人老去西风白发,蝶愁来明日黄花。","张可久《折桂令·九日》"] +["翩然欲下还飞去。","苏轼《渔家傲·千古龙蟠并虎踞》"] +["嗟我妇子,曰为改岁,入此室处。","佚名《七月》"] +["风流端合倚天公。","纳兰性德《浣溪沙·庚申除夜》"] +["水眄兰情,总平生稀见。","周邦彦《拜星月·高平秋思》"] +["冽彼下泉,浸彼苞稂。","佚名《下泉》"] +["旗梢不动晚波平。","张孝祥《浣溪沙·洞庭》"] +["徒设在昔心,良辰讵可待。","陶渊明《读山海经·其十》"] +["轻阴池馆水平桥,一番弄雨花梢。","陈子龙《画堂春·雨中杏花》"] +["一点微酸已著枝。","苏轼《南乡子·梅花词和杨元素》"] +["临其穴,惴惴其栗。","佚名《黄鸟》"] +["榈庭多落叶,慨然知已秋。","陶渊明《酬刘柴桑》"] +["不与行人绾离别,赋成谢女雪飞香。","纪映淮《咏秋柳》"] +["梧桐杨柳拂金井,来醉扶风豪士家。","李白《扶风豪士歌》"] +["稻云不雨不多黄,荞麦空花早着霜。","杨万里《悯农》"] +["诗言志,歌永言","《尚书·虞书·舜典》"] +["四牡骙骙,八鸾喈喈。","佚名《烝民》"] +["谣颂若采之,此言当可取。","元结《农臣怨》"] +["羔羊之皮,素丝五紽。","佚名《羔羊》"] +["璇闺玉墀上椒阁,文窗绣户垂罗幕。","鲍照《拟行路难十八首》"] +["宣室无由问釐事,周南何处托成书。","柳宗元《闻籍田有感》"] +["只应是,金笳暗拍,一样泪沾衣。","纳兰性德《满庭芳·堠雪翻鸦》"] +["何似浣纱溪畔住,绿阴相间两三家。","司空图《杨柳枝·桃源仙子不须夸》"] +["秋时自零落,春月复芬芳。","宋子侯《董娇饶》"] +["郧国稻苗秀,楚人菰米肥。","王维《送友人南归》"] +["秀眉霜雪颜桃花,骨青髓绿长美好。","李白《山人劝酒》"] diff --git a/internal/constant/version.go b/internal/constant/version.go new file mode 100644 index 0000000..8cf689b --- /dev/null +++ b/internal/constant/version.go @@ -0,0 +1,12 @@ +package constant + +import "time" + +// 构建时注入的变量 +var ( + Version = "dev" + BuildTime = "unknown" +) + +// 程序启动时间 +var StartTime = time.Now() diff --git a/internal/controllers/agent_controller.go b/internal/controllers/agent_controller.go new file mode 100644 index 0000000..63a7064 --- /dev/null +++ b/internal/controllers/agent_controller.go @@ -0,0 +1,746 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var agentUpgrader = websocket.Upgrader{ + CheckOrigin: utils.CheckWSOrigin, +} + +// AgentController Agent 控制器 +type AgentController struct { + agentService *services.AgentService + wsManager *services.AgentWSManager + settingsService *services.SettingsService +} + +// NewAgentController 创建 Agent 控制器 +func NewAgentController(settingsService *services.SettingsService) *AgentController { + return &AgentController{ + agentService: services.NewAgentService(), + wsManager: services.GetAgentWSManager(), + settingsService: settingsService, + } +} + +// List 获取 Agent 列表 +func (c *AgentController) List(ctx *gin.Context) { + agents := c.agentService.List() + utils.Success(ctx, vo.ToAgentVOListFromModels(agents)) +} + +// getActiveSchedulerConfig 获取 Agent 的实际调度配置(若为空或零值,则使用系统默认的 settings) +func (c *AgentController) getActiveSchedulerConfig(agent *models.Agent) map[string]interface{} { + workerCount := agent.SchedulerConfig.WorkerCount + queueSize := agent.SchedulerConfig.QueueSize + rateInterval := int(agent.SchedulerConfig.RateInterval / time.Millisecond) + strictQueue := agent.SchedulerConfig.StrictQueue + + // 如果未配置(WorkerCount <= 0),则使用全局系统设置 + if workerCount <= 0 { + workerCount = getIntSetting(c.settingsService, constant.SectionScheduler, constant.KeyWorkerCount, 4) + queueSize = getIntSetting(c.settingsService, constant.SectionScheduler, constant.KeyQueueSize, 100) + rateInterval = getIntSetting(c.settingsService, constant.SectionScheduler, constant.KeyRateInterval, 200) + strictQueue = false + } + + return map[string]interface{}{ + "worker_count": workerCount, + "queue_size": queueSize, + "rate_interval": rateInterval, + "strict_queue": strictQueue, + } +} + +// Update 更新 Agent +func (c *AgentController) Update(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + SchedulerConfig *vo.AgentSchedulerConfigVO `json:"scheduler_config"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + // 获取旧状态 + oldAgent := c.agentService.GetByID(id) + if oldAgent == nil { + utils.NotFound(ctx, "Agent 不存在") + return + } + wasEnabled := utils.DerefBool(oldAgent.Enabled, true) + + var schedulerConfig models.AgentSchedulerConfig + if req.SchedulerConfig != nil { + schedulerConfig.WorkerCount = req.SchedulerConfig.WorkerCount + schedulerConfig.QueueSize = req.SchedulerConfig.QueueSize + schedulerConfig.RateInterval = time.Duration(req.SchedulerConfig.RateInterval) * time.Millisecond + schedulerConfig.Verbose = req.SchedulerConfig.Verbose + schedulerConfig.StrictQueue = req.SchedulerConfig.StrictQueue + } + + if err := c.agentService.Update(id, req.Name, req.Description, req.Enabled, schedulerConfig); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + // 如果启用状态发生变化,通知 Agent + if wasEnabled != req.Enabled { + if req.Enabled { + // 启用:发送任务列表 + c.wsManager.SendToAgent(id, services.WSTypeEnabled, map[string]interface{}{ + "message": "Agent 已启用", + }) + // 发送任务列表 + c.wsManager.BroadcastTasks(id) + } else { + // 禁用:发送禁用消息,Agent 收到后清空任务 + c.wsManager.SendToAgent(id, services.WSTypeDisabled, map[string]interface{}{ + "message": "Agent 已禁用", + }) + } + } + + // 推送最新的调度配置给 Agent (如果 Agent 在线) + if req.Enabled { + // 重新加载已更新的 Agent 信息以获取正确的 SchedulerConfig + updatedAgent := c.agentService.GetByID(id) + if updatedAgent != nil { + c.wsManager.SendToAgent(id, services.WSTypeConnected, map[string]interface{}{ + "agent_id": id, + "name": req.Name, + "scheduler_config": c.getActiveSchedulerConfig(updatedAgent), + }) + } + } + + utils.SuccessMsg(ctx, "更新成功") +} + +// Delete 删除 Agent +func (c *AgentController) Delete(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + if err := c.agentService.Delete(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessMsg(ctx, "删除成功") +} + +// RegenerateToken 重新生成 Token +func (c *AgentController) RegenerateToken(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + token, err := c.agentService.RegenerateToken(id) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"token": token}) +} + +// ========== Agent API(供 Agent 调用)========== + +// Register Agent 注册(无需认证) +func (c *AgentController) Register(ctx *gin.Context) { + var req models.AgentRegisterRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + if req.Name == "" { + utils.BadRequest(ctx, "名称不能为空") + return + } + + ip := ctx.ClientIP() + agent, token, err := c.agentService.Register(&req, ip) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "agent_id": agent.ID, + "token": token, + "message": "注册成功", + }) +} + +// Heartbeat Agent 心跳 +func (c *AgentController) Heartbeat(ctx *gin.Context) { + token := c.getAgentToken(ctx) + if token == "" { + utils.Unauthorized(ctx, "缺少认证 Token") + return + } + + var req struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + AutoUpdate bool `json:"auto_update"` + } + ctx.ShouldBindJSON(&req) + + ip := ctx.ClientIP() + agent, err := c.agentService.Heartbeat(token, ip, req.Version, req.BuildTime, req.Hostname, req.OS, req.Arch) + if err != nil { + utils.Unauthorized(ctx, err.Error()) + return + } + + // 检查是否需要更新 + latestVersion := c.agentService.GetLatestVersion() + needUpdate := c.agentService.CheckNeedUpdate(req.Version, req.BuildTime) + forceUpdate := agent.ForceUpdate + + // 如果强制更新已触发,重置标志 + if forceUpdate && needUpdate { + c.agentService.ClearForceUpdate(agent.ID) + } + + utils.Success(ctx, gin.H{ + "agent_id": agent.ID, + "name": agent.Name, + "need_update": needUpdate, + "force_update": forceUpdate, + "latest_version": latestVersion, + }) +} + +// GetTasks Agent 获取任务列表 +func (c *AgentController) GetTasks(ctx *gin.Context) { + token := c.getAgentToken(ctx) + if token == "" { + utils.Unauthorized(ctx, "缺少认证 Token") + return + } + + // 先尝试通过 token 查找 Agent + agent := c.agentService.GetByToken(token) + + // 如果找不到,尝试验证令牌并通过 machine_id 查找 + if agent == nil { + machineID := ctx.GetHeader("X-Machine-ID") + if machineID != "" { + // 验证令牌是否有效 + if _, err := c.agentService.ValidateToken(token); err == nil { + // 令牌有效,尝试通过 machine_id 查找 Agent + agent = c.agentService.GetByMachineID(machineID) + } + } + } + + if agent == nil { + utils.Unauthorized(ctx, "无效的 Token") + return + } + + if !utils.DerefBool(agent.Enabled, true) { + utils.Forbidden(ctx, "Agent 已禁用") + return + } + + tasks := c.agentService.GetTasks(agent.ID) + utils.Success(ctx, gin.H{ + "agent_id": agent.ID, + "tasks": tasks, + }) +} + +// ReportResult Agent 上报执行结果 +func (c *AgentController) ReportResult(ctx *gin.Context) { + token := c.getAgentToken(ctx) + if token == "" { + utils.Unauthorized(ctx, "缺少认证 Token") + return + } + + agent := c.agentService.GetByToken(token) + if agent == nil { + utils.Unauthorized(ctx, "无效的 Token") + return + } + + if !utils.DerefBool(agent.Enabled, true) { + utils.Forbidden(ctx, "Agent 已禁用") + return + } + + var result models.AgentTaskResult + if err := ctx.ShouldBindJSON(&result); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + result.AgentID = agent.ID + + if err := c.agentService.ReportResult(&result); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessMsg(ctx, "上报成功") +} + +// getAgentToken 从请求头获取 Agent Token +func (c *AgentController) getAgentToken(ctx *gin.Context) string { + auth := ctx.GetHeader("Authorization") + if auth == "" { + return "" + } + // Bearer + parts := strings.SplitN(auth, " ", 2) + if len(parts) == 2 && parts[0] == "Bearer" { + return parts[1] + } + return auth +} + +// Download 下载 Agent 程序 +func (c *AgentController) Download(ctx *gin.Context) { + osType := ctx.DefaultQuery("os", "linux") + arch := ctx.DefaultQuery("arch", "amd64") + + data, filename, err := c.agentService.GetAgentBinary(osType, arch) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + ctx.Header("Content-Disposition", "attachment; filename="+filename) + ctx.Header("Content-Type", "application/gzip") + ctx.Header("Content-Length", strconv.Itoa(len(data))) + ctx.Data(200, "application/gzip", data) +} + +// GetVersion 获取 Agent 最新版本信息 +func (c *AgentController) GetVersion(ctx *gin.Context) { + version := c.agentService.GetLatestVersion() + platforms := c.agentService.GetAvailablePlatforms() + + utils.Success(ctx, gin.H{ + "version": version, + "platforms": platforms, + }) +} + +// ForceUpdate 强制更新指定 Agent +func (c *AgentController) ForceUpdate(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + if err := c.agentService.SetForceUpdate(id); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessMsg(ctx, "已标记强制更新,Agent 下次心跳时将自动更新") +} + +// ========== WebSocket ========== + +// WSConnect Agent WebSocket 连接 +func (c *AgentController) WSConnect(ctx *gin.Context) { + // 添加 panic 恢复 + defer func() { + if r := recover(); r != nil { + logger.Errorf("[AgentWS] WSConnect panic: %v", r) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误"}) + } + }() + + ip := ctx.ClientIP() + + // 打印请求信息用于调试 + logger.Infof("[AgentWS] 收到连接请求: IP=%s, URL=%s", ip, ctx.Request.URL.String()) + + // 检查 IP 限流 + if allowed, reason := c.wsManager.CheckRateLimit(ip); !allowed { + logger.Warnf("[AgentWS] IP %s 被限流: %s", ip, reason) + ctx.JSON(http.StatusTooManyRequests, gin.H{"error": reason}) + return + } + + token := ctx.Query("token") + if token == "" { + c.wsManager.RecordConnectFail(ip) + logger.Warnf("[AgentWS] 连接失败: 缺少 token, IP=%s", ip) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "缺少 token"}) + return + } + + machineID := ctx.Query("machine_id") + logger.Infof("[AgentWS] Token: %s..., MachineID: %s...", token[:8], machineID[:16]) + + isNewAgent := false + + // 先尝试用 token 查找已有 Agent + agent := c.agentService.GetByToken(token) + logger.Infof("[AgentWS] GetByToken 结果: agent=%v", agent != nil) + + // 如果没找到,尝试用令牌注册(会检查 machine_id 是否已存在) + if agent == nil { + logger.Infof("[AgentWS] 尝试注册新 Agent") + var err error + agent, isNewAgent, err = c.agentService.RegisterByToken(token, machineID, ip) + if err != nil { + c.wsManager.RecordConnectFail(ip) + logger.Warnf("[AgentWS] 注册失败: %v, IP=%s, token=%s", err, ip, token[:8]+"...") + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + logger.Infof("[AgentWS] 注册成功: Agent #%s, isNew=%v", agent.ID, isNewAgent) + } + + if !utils.DerefBool(agent.Enabled, true) { + c.wsManager.RecordConnectFail(ip) + logger.Warnf("[AgentWS] Agent #%s 已禁用, IP=%s", agent.ID, ip) + ctx.JSON(http.StatusForbidden, gin.H{"error": "Agent 已禁用"}) + return + } + + logger.Infof("[AgentWS] 准备升级连接: Agent #%s, IP=%s", agent.ID, ip) + conn, err := agentUpgrader.Upgrade(ctx.Writer, ctx.Request, nil) + if err != nil { + logger.Errorf("[AgentWS] 升级连接失败: %v, Agent #%s, IP=%s", err, agent.ID, ip) + return + } + conn.SetReadLimit(constant.MaxMessageSize) + + // 连接成功,重置失败计数 + c.wsManager.RecordConnectSuccess(ip) + + // 注册连接 + ac := c.wsManager.Register(agent.ID, conn, ip) + + // 更新 Agent 状态 + c.agentService.Heartbeat(token, ip, "", "", "", "", "") + + // 获取调度配置并发送连接成功消息(包含注册状态和调度配置) + schedCfg := c.getActiveSchedulerConfig(agent) + c.wsManager.SendToAgent(agent.ID, services.WSTypeConnected, map[string]interface{}{ + "agent_id": agent.ID, + "name": agent.Name, + "is_new_agent": isNewAgent, + "machine_id": machineID, + "scheduler_config": schedCfg, + }) + + logger.Infof("[AgentWS] Agent #%s 连接成功 (配置: %v)", agent.ID, schedCfg) + + // 启动读写协程 + go c.wsWritePump(ac) + go c.wsReadPump(ac, agent) + + // 主动推送任务列表 + go c.wsManager.BroadcastTasks(agent.ID) +} + +// wsReadPump 读取消息 +func (c *AgentController) wsReadPump(ac *services.AgentConnection, agent *models.Agent) { + defer func() { + if r := recover(); r != nil { + logger.Errorf("[AgentWS] Agent #%s wsReadPump panic: %v", agent.ID, r) + } + logger.Infof("[AgentWS] Agent #%s wsReadPump 退出", agent.ID) + c.wsManager.Unregister(agent.ID, ac) + }() + + // 检查连接是否有效(可能是旧连接被新连接替换) + if ac == nil || ac.IsClosed() { + return + } + + ac.SetReadDeadline(time.Now().Add(90 * time.Second)) + // 注意:SetPongHandler 需要直接访问 Conn,但这里我们在连接建立后立即设置 + // 所以是安全的,因为此时连接还没有被其他 goroutine 关闭 + ac.Conn.SetPongHandler(func(string) error { + ac.SetReadDeadline(time.Now().Add(90 * time.Second)) + return nil + }) + + for { + _, message, err := ac.ReadMessage() + if err != nil { + logger.Warnf("[AgentWS] Agent #%s 读取错误: %v", agent.ID, err) + break + } + + var msg services.WSMessage + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + c.handleWSMessage(ac, agent, &msg) + } +} + +// wsWritePump 写入消息 +func (c *AgentController) wsWritePump(ac *services.AgentConnection) { + defer func() { + if r := recover(); r != nil { + logger.Errorf("[AgentWS] Agent #%s wsWritePump panic: %v", ac.AgentID, r) + } + logger.Infof("[AgentWS] Agent #%s wsWritePump 退出", ac.AgentID) + }() + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case message, ok := <-ac.Send: + if !ok { + logger.Warnf("[AgentWS] Agent #%s Send channel 已关闭", ac.AgentID) + return + } + if ac.IsClosed() { + logger.Warnf("[AgentWS] Agent #%s 连接已关闭(write)", ac.AgentID) + return + } + if err := ac.WriteMessage(message); err != nil { + logger.Warnf("[AgentWS] Agent #%s 写入消息失败: %v", ac.AgentID, err) + return + } + case <-ticker.C: + if ac.IsClosed() { + return + } + if err := ac.WritePing(); err != nil { + logger.Warnf("[AgentWS] Agent #%s 发送 Ping 失败: %v", ac.AgentID, err) + return + } + } + } +} + +// handleWSMessage 处理 WebSocket 消息 +func (c *AgentController) handleWSMessage(ac *services.AgentConnection, agent *models.Agent, msg *services.WSMessage) { + switch msg.Type { + case services.WSTypeHeartbeat: + c.handleHeartbeat(ac, agent, msg.Data) + + case services.WSTypeTaskResult: + c.handleTaskResult(agent, msg.Data) + + case services.WSTypeTaskLog: + c.handleTaskLog(agent, msg.Data) + + case services.WSTypeFetchTasks: + c.handleFetchTasks(agent) + + case services.WSTypeTaskHeartbeat: // 任务心跳 + c.handleTaskHeartbeat(agent, msg.Data) + } +} + +// handleTaskHeartbeat 处理任务心跳 +func (c *AgentController) handleTaskHeartbeat(_ *models.Agent, data json.RawMessage) { + var req struct { + LogID string `json:"log_id"` + Duration int64 `json:"duration"` + } + if err := json.Unmarshal(data, &req); err != nil { + logger.Errorf("[AgentWS] 解析心跳消息失败: %v", err) + return + } + if req.LogID != "" { + logger.Infof("[AgentWS] 收到任务心跳: LogID=%s, Duration=%dms", req.LogID, req.Duration) + c.agentService.UpdateTaskDuration(req.LogID, req.Duration) + } +} + +// handleFetchTasks 处理 Agent 请求任务列表 +func (c *AgentController) handleFetchTasks(agent *models.Agent) { + tasks := c.agentService.GetTasks(agent.ID) + c.wsManager.SendToAgent(agent.ID, services.WSTypeTasks, map[string]interface{}{ + "tasks": tasks, + }) + logger.Infof("[AgentWS] Agent #%s 请求任务列表,返回 %d 个任务", agent.ID, len(tasks)) +} + +// handleHeartbeat 处理心跳 +func (c *AgentController) handleHeartbeat(ac *services.AgentConnection, agent *models.Agent, data json.RawMessage) { + var req struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + AutoUpdate bool `json:"auto_update"` + } + json.Unmarshal(data, &req) + + ac.UpdatePing() + + // 更新 Agent 信息(使用连接时保存的 IP) + c.agentService.Heartbeat(agent.Token, ac.IP, req.Version, req.BuildTime, req.Hostname, req.OS, req.Arch) + + // 检查是否需要更新 + latestVersion := c.agentService.GetLatestVersion() + needUpdate := c.agentService.CheckNeedUpdate(req.Version, req.BuildTime) + forceUpdate := agent.ForceUpdate + + if forceUpdate && needUpdate { + c.agentService.ClearForceUpdate(agent.ID) + } + + // 发送心跳响应 + response := map[string]interface{}{ + "agent_id": agent.ID, + "name": agent.Name, + "need_update": needUpdate, + "force_update": forceUpdate, + "latest_version": latestVersion, + } + c.wsManager.SendToAgent(agent.ID, services.WSTypeHeartbeatAck, response) +} + +// handleTaskResult 处理任务结果 +func (c *AgentController) handleTaskResult(agent *models.Agent, data json.RawMessage) { + var result models.AgentTaskResult + if err := json.Unmarshal(data, &result); err != nil { + return + } + + result.AgentID = agent.ID + c.agentService.ReportResult(&result) +} + +// handleTaskLog 处理 Agent 发送的实时日志 +func (c *AgentController) handleTaskLog(_ *models.Agent, data json.RawMessage) { + var logMsg struct { + LogID string `json:"log_id"` + Content string `json:"content"` + } + if err := json.Unmarshal(data, &logMsg); err != nil { + logger.Errorf("[AgentWS] 解析日志消息失败: %v", err) + return + } + + tl := tasks.GetActiveLog(logMsg.LogID) + if tl != nil { + tl.Write([]byte(logMsg.Content)) + } else { + logger.Warnf("[AgentWS] 收到任务日志 but could not find active TinyLog: LogID=%s, ContentSize=%d", logMsg.LogID, len(logMsg.Content)) + } +} + +// NotifyTaskUpdate 通知 Agent 任务更新 +func (c *AgentController) NotifyTaskUpdate(agentID string) { + c.wsManager.BroadcastTasks(agentID) +} + +// ========== 令牌管理 ========== + +// ListTokens 获取令牌列表 +func (c *AgentController) ListTokens(ctx *gin.Context) { + tokens := c.agentService.ListTokens() + utils.Success(ctx, vo.ToAgentTokenVOListFromModels(tokens)) +} + +// CreateToken 创建令牌 +func (c *AgentController) CreateToken(ctx *gin.Context) { + var req struct { + Remark string `json:"remark"` + MaxUses int `json:"max_uses"` + ExpiresAt string `json:"expires_at"` // 格式: 2006-01-02 15:04:05 + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + var expiresAt *time.Time + if req.ExpiresAt != "" { + t, err := time.ParseInLocation("2006-01-02 15:04:05", req.ExpiresAt, time.Local) + if err != nil { + utils.BadRequest(ctx, "过期时间格式错误") + return + } + expiresAt = &t + } + + token, err := c.agentService.CreateToken(req.Remark, req.MaxUses, expiresAt) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, vo.ToAgentTokenVO(token)) +} + +// DeleteToken 删除令牌 +func (c *AgentController) DeleteToken(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + if err := c.agentService.DeleteToken(id); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.SuccessMsg(ctx, "删除成功") +} + +// getIntSetting 辅助方法 +func getIntSetting(s *services.SettingsService, section, key string, defaultVal int) int { + val := s.Get(section, key) + + if val == "" { + return defaultVal + } + if result, err := strconv.Atoi(val); err == nil { + return result + } + return defaultVal +} diff --git a/internal/controllers/app_log_controller.go b/internal/controllers/app_log_controller.go new file mode 100644 index 0000000..6f8a06a --- /dev/null +++ b/internal/controllers/app_log_controller.go @@ -0,0 +1,79 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type AppLogController struct { + appLogService *services.AppLogService +} + +func NewAppLogController() *AppLogController { + return &AppLogController{ + appLogService: services.NewAppLogService(), + } +} + +// GetLogs 获取应用日志列表 +func (ac *AppLogController) GetLogs(c *gin.Context) { + p := utils.ParsePagination(c) + category := c.Query("category") + status := c.Query("status") + level := c.Query("level") + keyword := c.Query("keyword") + + logs, total, err := ac.appLogService.List(category, status, level, p.Page, p.PageSize, keyword) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + utils.PaginatedResponse(c, logs, total, p) +} + +// MarkAsRead 标记已读 +func (ac *AppLogController) MarkAsRead(c *gin.Context) { + var req struct { + ID string `json:"id"` + Category string `json:"category"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if req.ID != "" { + if err := ac.appLogService.MarkAsRead(req.ID); err != nil { + utils.BadRequest(c, err.Error()) + return + } + } else if req.Category != "" { + if err := ac.appLogService.MarkAllAsRead(req.Category); err != nil { + utils.BadRequest(c, err.Error()) + return + } + } else { + utils.BadRequest(c, "id 或 category 必须提供") + return + } + utils.SuccessMsg(c, "标记成功") +} + +// ClearLogs 清理日志 +func (ac *AppLogController) ClearLogs(c *gin.Context) { + var req struct { + Category string `json:"category"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if err := ac.appLogService.Clear(req.Category); err != nil { + utils.BadRequest(c, err.Error()) + return + } + utils.SuccessMsg(c, "清理成功") +} diff --git a/internal/controllers/auth_controller.go b/internal/controllers/auth_controller.go new file mode 100644 index 0000000..f0f4f60 --- /dev/null +++ b/internal/controllers/auth_controller.go @@ -0,0 +1,199 @@ +package controllers + +import ( + "strconv" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/middleware" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type AuthController struct { + userService *services.UserService + settingsService *services.SettingsService + loginLogService *services.LoginLogService +} + +type loginAttempt struct { + Count int + LastAttempt time.Time +} + +var loginAttempts sync.Map + +func init() { + // 定期清理过期的登录尝试统计,防止内存溢出 + go func() { + ticker := time.NewTicker(30 * time.Minute) + for range ticker.C { + loginAttempts.Range(func(key, value any) bool { + attempt := value.(*loginAttempt) + if time.Since(attempt.LastAttempt) > 10*time.Minute { + loginAttempts.Delete(key) + } + return true + }) + } + }() +} + +func NewAuthController(userService *services.UserService, settingsService *services.SettingsService, loginLogService *services.LoginLogService) *AuthController { + return &AuthController{ + userService: userService, + settingsService: settingsService, + loginLogService: loginLogService, + } +} + +func (ac *AuthController) Login(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 暴力破解防御 + if val, ok := loginAttempts.Load(ip); ok { + attempt := val.(*loginAttempt) + if attempt.Count >= 5 && time.Since(attempt.LastAttempt) < time.Minute { + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventBruteForceLogin, + Payload: map[string]interface{}{ + "ip": ip, + "username": req.Username, + "userAgent": userAgent, + }, + }) + utils.TooManyRequests(c, "尝试次数过多,请一分钟后再试") + return + } + // 如果距离上次尝试已超过一分钟,重置计数 + if time.Since(attempt.LastAttempt) >= time.Minute { + loginAttempts.Delete(ip) + } + } + + user := ac.userService.GetUserByUsername(req.Username) + if user == nil || !ac.userService.ValidatePassword(user, req.Password) { + // 记录失败尝试 + val, _ := loginAttempts.LoadOrStore(ip, &loginAttempt{Count: 0, LastAttempt: time.Now()}) + attempt := val.(*loginAttempt) + attempt.Count++ + attempt.LastAttempt = time.Now() + + // 记录登录失败日志 + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventUserLogin, + Payload: map[string]interface{}{ + "ip": ip, + "username": req.Username, + "userAgent": userAgent, + "status": "failed", + "message": "用户名或密码错误", + }, + }) + utils.Unauthorized(c, "用户名或密码错误") + return + } + + // 登录成功,清除尝试记录 + loginAttempts.Delete(ip) + + // 获取 cookie 过期天数 + expireDays := 7 + if days := ac.settingsService.Get(constant.SectionSite, constant.KeyCookieDays); days != "" { + if d, err := strconv.Atoi(days); err == nil && d > 0 { + expireDays = d + } + } + + // 生成 token + token, err := utils.GenerateToken(user.ID, user.Username, user.TokenVersion, expireDays, constant.Secret) + if err != nil { + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventUserLogin, + Payload: map[string]interface{}{ + "ip": ip, + "username": req.Username, + "userAgent": userAgent, + "status": "failed", + "message": "Token生成失败", + }, + }) + utils.ServerError(c, "登录失败") + return + } + + // 设置 Cookie + middleware.SetAuthCookie(c, token, expireDays) + + // 记录登录成功日志 + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventUserLogin, + Payload: map[string]interface{}{ + "ip": ip, + "username": req.Username, + "userAgent": userAgent, + "status": "success", + "message": "登录成功", + }, + }) + + utils.Success(c, gin.H{ + "user": user.Username, + }) +} + +func (ac *AuthController) Logout(c *gin.Context) { + if userID, exists := c.Get("userID"); exists { + ac.userService.InvalidateUserTokens(userID.(string)) + } + middleware.ClearAuthCookie(c) + utils.SuccessMsg(c, "退出成功") +} + +func (ac *AuthController) GetCurrentUser(c *gin.Context) { + userID := c.GetString("userID") + user, err := ac.userService.GetUserByID(userID) + if err != nil { + utils.Unauthorized(c, "会话无效") + return + } + utils.Success(c, gin.H{ + "username": user.Username, + "role": user.Role, + }) +} + +func (ac *AuthController) Register(c *gin.Context) { + /* + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 安全性:强制设定角色为 user,防止注册时篡改角色为 admin + user := ac.userService.CreateUser(req.Username, req.Password, req.Email, constant.DefaultRole) + utils.Success(c, vo.ToUserVO(user)) + */ + utils.BadRequest(c, "注册功能已关闭") +} diff --git a/internal/controllers/dashboard_controller.go b/internal/controllers/dashboard_controller.go new file mode 100644 index 0000000..eb99b73 --- /dev/null +++ b/internal/controllers/dashboard_controller.go @@ -0,0 +1,202 @@ +package controllers + +import ( + "sort" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type DashboardController struct { + executorService *tasks.ExecutorService +} + +func NewDashboardController(executorService *tasks.ExecutorService) *DashboardController { + return &DashboardController{ + executorService: executorService, + } +} + +type StatsResponse struct { + Tasks int64 `json:"tasks"` + TodayExecs int64 `json:"today_execs"` + Envs int64 `json:"envs"` + Logs int64 `json:"logs"` + Scheduled int `json:"scheduled"` + Running int `json:"running"` +} + +func (dc *DashboardController) GetStats(c *gin.Context) { + var taskCount, envCount, logCount, todayExecs int64 + + database.DB.Model(&models.Task{}).Count(&taskCount) + database.DB.Model(&models.EnvironmentVariable{}).Count(&envCount) + database.DB.Model(&models.TaskLog{}).Count(&logCount) + + // 今日执行总数 + today := time.Now().Format("2006-01-02") + database.DB.Model(&models.SendStats{}).Where("day = ?", today).Select("COALESCE(SUM(num), 0)").Scan(&todayExecs) + + // 调度统计:本地调度 + Agent 调度 + // 本地调度:agent_id 为 NULL 且 enabled = true 的任务 + localScheduled := dc.executorService.GetScheduledCount() + + // Agent 调度:agent_id 不为 NULL 且 enabled = true 的任务 + var agentScheduled int64 + database.DB.Model(&models.Task{}). + Where("agent_id IS NOT NULL AND enabled = ?", true). + Count(&agentScheduled) + + totalScheduled := localScheduled + int(agentScheduled) + + // 正在运行:目前只能统计本地运行的任务 + // Agent 端的运行状态需要通过心跳上报(未来优化) + running := dc.executorService.GetRunningCount() + + stats := StatsResponse{ + Tasks: taskCount, + TodayExecs: todayExecs, + Envs: envCount, + Logs: logCount, + Scheduled: totalScheduled, + Running: running, + } + + utils.Success(c, stats) +} + +// GetSentence 获取随机古诗词 +func (dc *DashboardController) GetSentence(c *gin.Context) { + utils.Success(c, gin.H{ + "sentence": constant.GetRandomSentence(), + }) +} + +// DailyStats 每日统计数据 +type DailyStats struct { + Day string `json:"day"` + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` +} + +// GetSendStats 获取发送统计 +func (dc *DashboardController) GetSendStats(c *gin.Context) { + // 获取天数参数,默认30天 + days := 30 + if d := c.Query("days"); d != "" { + if parsed, err := utils.ParseInt(d); err == nil && parsed > 0 && parsed <= 90 { + days = parsed + } + } + + // 获取日期范围 + now := time.Now() + startDay := now.AddDate(0, 0, -(days - 1)).Format("2006-01-02") + + var stats []models.SendStats + database.DB.Where("day >= ?", startDay).Find(&stats) + + // 按日期聚合 + dayMap := make(map[string]*DailyStats) + for _, s := range stats { + if _, ok := dayMap[s.Day]; !ok { + dayMap[s.Day] = &DailyStats{Day: s.Day} + } + ds := dayMap[s.Day] + ds.Total += s.Num + if s.Status == constant.TaskStatusSuccess { + ds.Success += s.Num + } else { + ds.Failed += s.Num + } + } + + // 填充缺失的日期 + result := make([]DailyStats, 0, days) + for i := days - 1; i >= 0; i-- { + day := now.AddDate(0, 0, -i).Format("2006-01-02") + if ds, ok := dayMap[day]; ok { + result = append(result, *ds) + } else { + result = append(result, DailyStats{Day: day}) + } + } + + // 按日期排序 + sort.Slice(result, func(i, j int) bool { + return result[i].Day < result[j].Day + }) + + utils.Success(c, result) +} + +// TaskStats 任务执行统计 +type TaskStats struct { + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + Count int `json:"count"` +} + +// GetTaskStats 获取任务执行占比 +func (dc *DashboardController) GetTaskStats(c *gin.Context) { + // 获取天数参数,默认30天 + days := 30 + if d := c.Query("days"); d != "" { + if parsed, err := utils.ParseInt(d); err == nil && parsed > 0 && parsed <= 90 { + days = parsed + } + } + + now := time.Now() + startDay := now.AddDate(0, 0, -(days - 1)).Format("2006-01-02") + + // 按 task_id 聚合统计 + var results []struct { + TaskID string + Total int + } + database.DB.Model(&models.SendStats{}). + Select("task_id, SUM(num) as total"). + Where("day >= ?", startDay). + Group("task_id"). + Order("total DESC"). + Find(&results) + + // 获取任务名称 + taskIDs := make([]string, 0, len(results)) + for _, r := range results { + taskIDs = append(taskIDs, r.TaskID) + } + + var tasks []models.Task + if len(taskIDs) > 0 { + database.DB.Where("id IN ?", taskIDs).Find(&tasks) + } + taskNameMap := make(map[string]string) + for _, t := range tasks { + taskNameMap[t.ID] = t.Name + } + + // 构建结果 + stats := make([]TaskStats, 0, len(results)) + for _, r := range results { + name := taskNameMap[r.TaskID] + if name == "" { + name = "未知任务" + } + stats = append(stats, TaskStats{ + TaskID: r.TaskID, + TaskName: name, + Count: r.Total, + }) + } + + utils.Success(c, stats) +} diff --git a/internal/controllers/data_controller.go b/internal/controllers/data_controller.go new file mode 100644 index 0000000..da0681a --- /dev/null +++ b/internal/controllers/data_controller.go @@ -0,0 +1,81 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + "github.com/gin-gonic/gin" +) + +type DataController struct { + dataService *services.DataService + taskController *TaskController + envController *EnvController +} + +func NewDataController(tc *TaskController, ec *EnvController) *DataController { + return &DataController{ + dataService: services.NewDataService(), + taskController: tc, + envController: ec, + } +} + +// ExportBusinessData 导出业务数据 +func (dc *DataController) ExportBusinessData(c *gin.Context) { + var req struct { + TaskIDs []string `json:"task_ids"` + EnvIDs []string `json:"env_ids"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + exportData := dc.dataService.ExportBusinessData(req.TaskIDs, req.EnvIDs) + utils.Success(c, exportData) +} + +// ImportBusinessData 导入业务数据 +func (dc *DataController) ImportBusinessData(c *gin.Context) { + var req models.ExportData + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if req.Version == "" { + utils.BadRequest(c, "无效的导入数据格式") + return + } + + // 停止相关的定时任务 + if len(req.Tasks) > 0 { + for _, task := range req.Tasks { + dc.taskController.executorService.RemoveCronTask(task.ID) + dc.taskController.executorService.GetScheduler().StopTask(task.ID) + } + } + + // 导入数据 + if err := dc.dataService.ImportBusinessData(&req); err != nil { + utils.ServerError(c, "导入失败: "+err.Error()) + return + } + + // 重新启动任务和通知相关的代理 + if len(req.Tasks) > 0 { + for i := range req.Tasks { + task := &req.Tasks[i] + if utils.DerefBool(task.Enabled, true) && (task.AgentID == nil || *task.AgentID == "") { + dc.taskController.executorService.AddCronTask(task) + } + if task.AgentID != nil && *task.AgentID != "" { + dc.taskController.agentWSManager.BroadcastTasks(*task.AgentID) + } + } + } + + utils.SuccessMsg(c, "导入成功") +} diff --git a/internal/controllers/dependency_controller.go b/internal/controllers/dependency_controller.go new file mode 100644 index 0000000..29e0c82 --- /dev/null +++ b/internal/controllers/dependency_controller.go @@ -0,0 +1,421 @@ +package controllers + +import ( + "fmt" + "os" + "strings" + + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/deps" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type DependencyController struct { + service *services.DependencyService +} + +func NewDependencyController() *DependencyController { + return &DependencyController{ + service: services.NewDependencyService(), + } +} + +// List 获取依赖列表 +func (c *DependencyController) List(ctx *gin.Context) { + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + deps, err := c.service.List(language, langVersion) + if err != nil { + utils.ServerError(ctx, "获取依赖列表失败") + return + } + vos := vo.ToDependencyVOListFromModels(deps) + utils.Success(ctx, vos) +} + +// Create 添加依赖 +func (c *DependencyController) Create(ctx *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language" binding:"required"` + LangVersion string `json:"lang_version"` + Remark string `json:"remark"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + dep := &models.Dependency{ + Name: req.Name, + Version: req.Version, + Language: req.Language, + LangVersion: req.LangVersion, + Remark: req.Remark, + } + + if err := c.service.Create(dep); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, vo.ToDependencyVO(dep)) +} + +// Delete 删除依赖 +func (c *DependencyController) Delete(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + if err := c.service.Delete(id); err != nil { + utils.ServerError(ctx, "删除失败") + return + } + + utils.SuccessMsg(ctx, "删除成功") +} + +func (c *DependencyController) Install(ctx *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + Remark string `json:"remark"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + language := req.Language + if language == "" { + language = ctx.Query("language") + } + langVersion := req.LangVersion + if langVersion == "" { + langVersion = ctx.Query("lang_version") + } + + dep := &models.Dependency{ + Name: req.Name, + Version: req.Version, + Language: language, + LangVersion: langVersion, + Remark: req.Remark, + } + + err := c.service.Install(dep) + // 无论成功失败,都同步记录日志 + c.service.Create(dep) + + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessMsg(ctx, "安装成功") +} + +// GetInstallCommand 获取安装命令 +func (c *DependencyController) GetInstallCommand(ctx *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + language := req.Language + if language == "" { + language = ctx.Query("language") + } + langVersion := req.LangVersion + if langVersion == "" { + langVersion = ctx.Query("lang_version") + } + + dep := &models.Dependency{ + Name: req.Name, + Version: req.Version, + Language: language, + LangVersion: langVersion, + } + + cmd, err := c.service.GetInstallCommand(dep) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"command": cmd}) +} + +// GetReinstallAllCommand 获取全部重装命令 +func (c *DependencyController) GetReinstallAllCommand(ctx *gin.Context) { + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + if language == "" { + utils.BadRequest(ctx, "缺少 language 参数") + return + } + + cmd, err := c.service.GetReinstallAllCommand(language, langVersion) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"command": cmd}) +} + +// Uninstall 卸载依赖 +func (c *DependencyController) Uninstall(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + force := ctx.Query("force") == "true" + + // 获取依赖信息 + deps, _ := c.service.List("", "") + var dep *models.Dependency + for i := range deps { + if deps[i].ID == id { + dep = &deps[i] + break + } + } + + if dep == nil { + utils.NotFound(ctx, "依赖不存在") + return + } + + if err := c.service.Uninstall(dep); err != nil { + if !force { + utils.ServerError(ctx, err.Error()) + return + } + } + + // 卸载成功(或强制删除)后从数据库删除 + c.service.Delete(id) + + utils.SuccessMsg(ctx, "卸载成功") +} + +// Reinstall 重新安装依赖 +func (c *DependencyController) Reinstall(ctx *gin.Context) { + id := ctx.Param("id") + if id == "" { + utils.BadRequest(ctx, "无效的 ID") + return + } + + // 获取依赖信息 + deps, _ := c.service.List("", "") + var dep *models.Dependency + for i := range deps { + if deps[i].ID == id { + dep = &deps[i] + break + } + } + + if dep == nil { + utils.NotFound(ctx, "依赖不存在") + return + } + + err := c.service.Install(dep) + // 无论成功失败,都同步记录日志 + c.service.Create(dep) + + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessMsg(ctx, "重新安装成功") +} + +// ReinstallAll 重新安装所有依赖 +func (c *DependencyController) ReinstallAll(ctx *gin.Context) { + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + if language == "" { + utils.BadRequest(ctx, "缺少 language 参数") + return + } + + deps, err := c.service.List(language, langVersion) + if err != nil { + utils.ServerError(ctx, "获取依赖列表失败") + return + } + + var failed []string + for i := range deps { + d := &deps[i] + err := c.service.Install(d) + if err != nil { + failed = append(failed, d.Name) + } + // 无论成功失败,都同步记录日志到数据库 + c.service.Create(d) + } + + if len(failed) > 0 { + utils.ServerError(ctx, "部分包安装失败: "+strings.Join(failed, ", ")) + return + } + + utils.SuccessMsg(ctx, "全部重新安装成功") +} + +// GetInstalled 获取已安装的包 +func (c *DependencyController) GetInstalled(ctx *gin.Context) { + language := ctx.Query("language") + langVersion := ctx.Query("lang_version") + if language == "" { + utils.BadRequest(ctx, "缺少 language 参数") + return + } + + packages, err := c.service.GetInstalledPackages(language, langVersion) + if err != nil { + utils.ServerError(ctx, "获取已安装包失败: "+err.Error()) + return + } + + utils.Success(ctx, packages) +} + +// GetBatchInstallCommand 获取批量安装依赖包的命令 +func (c *DependencyController) GetBatchInstallCommand(ctx *gin.Context) { + var req struct { + Items []struct { + Name string `json:"name" binding:"required"` + Version string `json:"version"` + Language string `json:"language" binding:"required"` + LangVersion string `json:"lang_version"` + } `json:"items" binding:"required,gt=0"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: items 不能为空且必须包含 name 和 language") + return + } + + var depsList []models.Dependency + for _, item := range req.Items { + depsList = append(depsList, models.Dependency{ + Name: item.Name, + Version: item.Version, + Language: item.Language, + LangVersion: item.LangVersion, + }) + } + + cmd, err := c.service.GetBatchInstallCommand(depsList) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"command": cmd}) +} + +// ParseAndImport 解析上传/粘贴的清单文件内容并批量导入至数据库 +func (c *DependencyController) ParseAndImport(ctx *gin.Context) { + var req struct { + Language string `json:"language" binding:"required"` + LangVersion string `json:"lang_version"` + Content string `json:"content" binding:"required"` + ImportDB bool `json:"import_db"` // 是否持久化到数据库做可视化管理 + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: language 和 content 必填") + return + } + + // 1. 解析文本清单内容 + parsedDeps, err := deps.ParseManifest(req.Language, req.Content) + if err != nil { + utils.ServerError(ctx, "清单文件解析失败: "+err.Error()) + return + } + + if len(parsedDeps) == 0 { + utils.BadRequest(ctx, "未解析到任何有效依赖包") + return + } + + // 2. 补全语言和版本属性 + for i := range parsedDeps { + parsedDeps[i].Language = req.Language + parsedDeps[i].LangVersion = req.LangVersion + } + + // 3. 根据需求决定是否导入数据库 + var finalDeps []models.Dependency + if req.ImportDB { + imported, err := c.service.ImportDependencies(parsedDeps) + if err != nil { + utils.ServerError(ctx, "导入依赖记录至数据库失败: "+err.Error()) + return + } + finalDeps = imported + } else { + finalDeps = parsedDeps + } + + // 4. 为这一批包生成合并批量安装命令 + cmd, err := c.service.GetBatchInstallCommand(finalDeps) + if err != nil { + utils.ServerError(ctx, "生成安装命令失败: "+err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "dependencies": vo.ToDependencyVOListFromModels(finalDeps), + "command": cmd, + }) +} + +// GetDepInstallCommand 获取自动补全的命令,返回给前端执行 +func (c *DependencyController) GetDepInstallCommand(ctx *gin.Context) { + logID := ctx.Query("log_id") + if logID == "" { + utils.BadRequest(ctx, "参数错误: log_id 不能为空") + return + } + + execPath, err := os.Executable() + if err != nil { + execPath = "taskpool" // 兜底 + } + + // 构造命令,比如: "F:\workspace\taskpool\taskpool.exe" depinstall + cmdStr := fmt.Sprintf("%q depinstall %s", execPath, logID) + utils.Success(ctx, gin.H{ + "command": cmdStr, + }) +} diff --git a/internal/controllers/env_controller.go b/internal/controllers/env_controller.go new file mode 100644 index 0000000..e456cfb --- /dev/null +++ b/internal/controllers/env_controller.go @@ -0,0 +1,389 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/relation" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type EnvController struct { + envService *services.EnvService +} + +func NewEnvController(envService *services.EnvService) *EnvController { + return &EnvController{envService: envService} +} + +// GetSecretStatus 获取加密秘钥状态 +// @Summary 获取加密秘钥状态 +// @Description 返回系统是否已配置加密秘钥 +// @Tags Env +// @Produce json +// @Success 200 {object} utils.Response{data=bool} "成功" +// @Router /env/secret-status [get] +// @Security BearerAuth +func (ec *EnvController) GetSecretStatus(c *gin.Context) { + utils.Success(c, utils.IsSecretKeySet()) +} + +// CreateEnvVar 创建环境变量 +// @Summary 创建环境变量 +// @Description 创建一个新的环境变量 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object true "环境变量信息" +// @Success 200 {object} utils.Response{data=vo.EnvVO} +// @Router /env [post] +func (ec *EnvController) CreateEnvVar(c *gin.Context) { + userID := c.GetString("userID") + + var req struct { + Name string `json:"name" binding:"required"` + Value string `json:"value" binding:"required"` + Remark string `json:"remark"` + Type string `json:"type"` + Hidden *bool `json:"hidden"` + Enabled *bool `json:"enabled"` + Tags string `json:"tags"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if req.Type == "" { + req.Type = constant.EnvTypeNormal + } + + hidden := true + if req.Hidden != nil { + hidden = *req.Hidden + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + envVar := ec.envService.CreateEnvVar(req.Name, req.Value, req.Remark, req.Type, hidden, enabled, userID) + if envVar != nil { + relation.DataRelation.SaveTags(envVar.ID, constant.RelationTypeEnvTag, req.Tags) + envVar.Tags = req.Tags + } + + // Broadcast tasks to all agents because global envs changed + services.GetAgentWSManager().BroadcastTasksToAll() + + utils.Success(c, vo.ToEnvVO(envVar)) +} + +// GetEnvVars 获取环境变量列表 +// @Summary 获取环境变量列表 +// @Description 分页获取环境变量列表,支持按名称筛选 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param name query string false "按名称模糊查询" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param type query string false "按类型筛选" +// @Param tags query string false "按标签筛选" +// @Success 200 {object} utils.Response{data=utils.PaginationData{data=[]vo.EnvVO}} +// @Router /env [get] +func (ec *EnvController) GetEnvVars(c *gin.Context) { + userID := c.GetString("userID") + p := utils.ParsePagination(c) + name := c.DefaultQuery("name", "") + envType := c.DefaultQuery("type", "") + tags := c.DefaultQuery("tags", "") + envVars, total := ec.envService.GetEnvVarsWithPagination(userID, name, envType, tags, p.Page, p.PageSize) + utils.PaginatedResponse(c, vo.ToEnvVOListFromModels(envVars), total, p) +} + +// GetAllEnvVars 获取所有环境变量 +// @Summary 获取所有环境变量 +// @Description 获取当前用户的所有环境变量(不分页) +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=[]vo.EnvVO} +// @Router /env/all [get] +func (ec *EnvController) GetAllEnvVars(c *gin.Context) { + userID := c.GetString("userID") + envVars := ec.envService.GetEnvVarsByUserID(userID) + utils.Success(c, vo.ToEnvVOListFromModels(envVars)) +} + +// GetEnvVar 获取环境变量详情 +// @Summary 获取环境变量详情 +// @Description 根据 ID 获取环境变量详情 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "环境变量ID" +// @Success 200 {object} utils.Response{data=vo.EnvVO} +// @Failure 404 {object} utils.Response +// @Router /env/{id} [get] +func (ec *EnvController) GetEnvVar(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的环境变量ID") + return + } + + envVar := ec.envService.GetEnvVarByID(id) + if envVar == nil { + utils.NotFound(c, "环境变量不存在") + return + } + + utils.Success(c, vo.ToEnvVO(envVar)) +} + +// UpdateEnvVar 更新环境变量 +// @Summary 更新环境变量 +// @Description 根据 ID 更新环境变量信息 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "环境变量ID" +// @Param body body object true "环境变量更新信息" +// @Success 200 {object} utils.Response{data=vo.EnvVO} +// @Failure 404 {object} utils.Response +// @Router /env/{id} [put] +func (ec *EnvController) UpdateEnvVar(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的环境变量ID") + return + } + + var req struct { + Name string `json:"name"` + Value string `json:"value"` + Remark string `json:"remark"` + Type string `json:"type"` + Hidden *bool `json:"hidden"` + Enabled *bool `json:"enabled"` + Tags string `json:"tags"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if req.Type == "" { + req.Type = constant.EnvTypeNormal + } + + // 对于更新,获取现有数据 + existing := ec.envService.GetEnvVarByID(id) + if existing == nil { + utils.NotFound(c, "环境变量不存在") + return + } + + hidden := existing.Hidden + if req.Hidden != nil { + hidden = req.Hidden + } + + enabled := existing.Enabled + if req.Enabled != nil { + enabled = req.Enabled + } + + envVar := ec.envService.UpdateEnvVar(id, req.Name, req.Value, req.Remark, req.Type, utils.DerefBool(hidden, true), utils.DerefBool(enabled, true)) + if envVar == nil { + utils.NotFound(c, "环境变量不存在") + return + } + + relation.DataRelation.SaveTags(envVar.ID, constant.RelationTypeEnvTag, req.Tags) + envVar.Tags = req.Tags + + // Broadcast tasks to all agents because global envs changed + services.GetAgentWSManager().BroadcastTasksToAll() + + utils.Success(c, vo.ToEnvVO(envVar)) +} + +// DeleteEnvVar 删除环境变量 +// @Summary 删除环境变量 +// @Description 根据 ID 删除环境变量 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "环境变量ID" +// @Param force query boolean false "强制删除(忽略任务关联)" +// @Success 200 {object} utils.Response +// @Failure 404 {object} utils.Response +// @Failure 409 {object} utils.Response{data=[]vo.TaskVO} +// @Router /env/{id} [delete] +func (ec *EnvController) DeleteEnvVar(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的环境变量ID") + return + } + + force := c.Query("force") == "true" + success, associatedTasks := ec.envService.DeleteEnvVar(id, force) + + if len(associatedTasks) > 0 { + c.JSON(200, utils.Response{ + Code: 409, + Msg: "该环境变量已被任务引用,请先在任务中删除引用或选择强制删除", + Data: vo.ToTaskVOListFromModels(associatedTasks), + }) + return + } + + if !success { + utils.NotFound(c, "环境变量不存在或删除失败") + return + } + + // Broadcast tasks to all agents because global envs changed + services.GetAgentWSManager().BroadcastTasksToAll() + + utils.SuccessMsg(c, "删除成功") +} + +// GetAssociatedTasks 获取关联任务 +// @Summary 获取关联任务 +// @Description 获取引用了该环境变量的任务列表 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "环境变量ID" +// @Success 200 {object} utils.Response{data=[]vo.TaskVO} +// @Router /env/{id}/tasks [get] +func (ec *EnvController) GetAssociatedTasks(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的环境变量ID") + return + } + tasks := ec.envService.GetAssociatedTasks(id) + utils.Success(c, vo.ToTaskVOListFromModels(tasks)) +} + +// GetTags 获取所有环境变量标签 +// @Summary 获取所有环境变量标签 +// @Description 获取所有环境变量中使用的标签列表 +// @Tags 环境变量 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=[]string} +// @Router /env/tags [get] +func (ec *EnvController) GetTags(c *gin.Context) { + tags, err := ec.envService.GetAllEnvTags() + if err != nil { + utils.ServerError(c, "获取标签失败") + return + } + utils.Success(c, tags) +} + +// BulkSaveEnv 批量保存环境变量 +func (ec *EnvController) BulkSaveEnv(c *gin.Context) { + var reqs []struct { + ID string `json:"id"` + Name string `json:"name" binding:"required"` + Value string `json:"value" binding:"required"` + Remark string `json:"remark"` + Type string `json:"type"` + Hidden *bool `json:"hidden"` + Enabled *bool `json:"enabled"` + } + + if err := c.ShouldBindJSON(&reqs); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + userID := c.GetString("userID") + + for _, req := range reqs { + if req.Type == constant.EnvTypeSecret { + continue // 二次严苛拦截,机密变量不应下发/保存 + } + + hidden := true + if req.Hidden != nil { + hidden = *req.Hidden + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + var existingEnv *models.EnvironmentVariable + // 优先按 ID 匹配 + if req.ID != "" { + var e models.EnvironmentVariable + if err := database.DB.Where("id = ?", req.ID).First(&e).Error; err == nil { + existingEnv = &e + } + } + // 如果 ID 没找到,按 Name 匹配 + if existingEnv == nil { + var e models.EnvironmentVariable + if err := database.DB.Where("name = ?", req.Name).First(&e).Error; err == nil { + existingEnv = &e + } + } + + if existingEnv != nil { + existingEnv.Name = req.Name + existingEnv.Value = models.BigText(req.Value) + existingEnv.Remark = req.Remark + existingEnv.Type = req.Type + existingEnv.Hidden = &hidden + existingEnv.Enabled = &enabled + database.DB.Save(existingEnv) + + if req.ID != "" && existingEnv.ID != req.ID { + database.DB.Model(existingEnv).Update("id", req.ID) + } + } else { + envVar := &models.EnvironmentVariable{ + ID: req.ID, + Name: req.Name, + Value: models.BigText(req.Value), + Remark: req.Remark, + Type: req.Type, + Hidden: &hidden, + Enabled: &enabled, + UserID: userID, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if envVar.ID == "" { + envVar.ID = utils.GenerateID() + } + database.DB.Create(envVar) + } + } + + services.GetAgentWSManager().BroadcastTasksToAll() + utils.Success(c, nil) +} diff --git a/internal/controllers/executor_controller.go b/internal/controllers/executor_controller.go new file mode 100644 index 0000000..8c822c2 --- /dev/null +++ b/internal/controllers/executor_controller.go @@ -0,0 +1,92 @@ +package controllers + +import ( + "strconv" + + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type ExecutorController struct { + executorService *tasks.ExecutorService +} + +func NewExecutorController(executorService *tasks.ExecutorService) *ExecutorController { + return &ExecutorController{executorService: executorService} +} + +// ExecuteTask 运行任务 +// @Summary 运行任务 +// @Description 立即执行指定的任务 +// @Tags 任务执行 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "任务ID" +// @Param body body object false "执行参数 (envs: 环境变量字典)" +// @Success 200 {object} utils.Response{data=vo.ExecutionResultVO} +// @Failure 400 {object} utils.Response +// @Router /execute/task/{id} [post] +func (ec *ExecutorController) ExecuteTask(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的任务ID") + return + } + + var req struct { + Envs map[string]string `json:"envs"` + } + // 尝试绑定 JSON 体,但不强制要求 + _ = c.ShouldBindJSON(&req) + + var extraEnvs []string + if req.Envs != nil { + for k, v := range req.Envs { + extraEnvs = append(extraEnvs, k+"="+v) + } + } + + result := ec.executorService.ExecuteTask(id, extraEnvs) + utils.Success(c, vo.ToExecutionResultVO(result)) +} + +// ExecuteCommand 执行命令 +func (ec *ExecutorController) ExecuteCommand(c *gin.Context) { + var req struct { + Command string `json:"command" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + result := ec.executorService.ExecuteCommand(req.Command) + utils.Success(c, vo.ToExecutionResultVO(result)) +} + +// GetLastResults 获取最新执行结果 +// @Summary 获取最新执行结果 +// @Description 获取最新任务或命令执行的结果列表 +// @Tags 任务执行 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param count query int false "数量 (默认 10)" +// @Success 200 {object} utils.Response{data=[]vo.ExecutionResultVO} +// @Router /execute/results [get] +func (ec *ExecutorController) GetLastResults(c *gin.Context) { + count := 10 + if c.Query("count") != "" { + if parsedCount, err := strconv.Atoi(c.Query("count")); err == nil && parsedCount > 0 { + count = parsedCount + } + } + + results := ec.executorService.GetLastResults(count) + utils.Success(c, vo.ToExecutionResultVOList(results)) +} diff --git a/internal/controllers/file_controller.go b/internal/controllers/file_controller.go new file mode 100644 index 0000000..40ba4a8 --- /dev/null +++ b/internal/controllers/file_controller.go @@ -0,0 +1,550 @@ +package controllers + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +var ( + extractZip = utils.ExtractZip + extractTar = utils.ExtractTar + extractTarGz = utils.ExtractTarGz +) + +type FileController struct { + workDir string +} + +func NewFileController(workDir string) *FileController { + os.MkdirAll(workDir, 0755) + absPath, err := filepath.Abs(workDir) + if err != nil { + absPath = workDir + } + return &FileController{workDir: absPath} +} + +type FileNode struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + ModTime int64 `json:"modTime"` + Children []*FileNode `json:"children,omitempty"` +} + +// checkPath 校验路径是否在工作目录内且安全。 +// 它返回完整的绝对路径以及一个表示路径是否安全的布尔值。 +func (fc *FileController) checkPath(path string, allowRoot bool) (string, bool) { + fullPath := filepath.Join(fc.workDir, filepath.Clean(path)) + rel, err := filepath.Rel(fc.workDir, fullPath) + if err != nil { + return "", false + } + + // 基础的目录穿越检查 + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + + // 根目录检查 + if !allowRoot && rel == "." { + return "", false + } + + return fullPath, true +} + +func (fc *FileController) GetFileTree(c *gin.Context) { + root := &FileNode{ + Name: filepath.Base(fc.workDir), + Path: "", + IsDir: true, + Children: []*FileNode{}, + } + + err := filepath.WalkDir(fc.workDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if path == fc.workDir { + return nil + } + + // 过滤 __pycache__ 文件夹 + if d.IsDir() && d.Name() == "__pycache__" { + return filepath.SkipDir + } + + relPath, _ := filepath.Rel(fc.workDir, path) + parts := strings.Split(relPath, string(filepath.Separator)) + + info, err := d.Info() + var modTime int64 + if err == nil { + modTime = info.ModTime().UnixMilli() + } + + current := root + for i, part := range parts { + found := false + for _, child := range current.Children { + if child.Name == part { + current = child + found = true + break + } + } + if !found { + isLast := i == len(parts)-1 + isDir := !isLast || d.IsDir() + node := &FileNode{ + Name: part, + Path: strings.Join(parts[:i+1], "/"), + IsDir: isDir, + ModTime: modTime, + } + if isDir { + node.Children = []*FileNode{} + } + current.Children = append(current.Children, node) + current = node + } + } + return nil + }) + + if err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, root.Children) +} + +func (fc *FileController) GetFileContent(c *gin.Context) { + filePath := c.Query("path") + if filePath == "" { + utils.BadRequest(c, "path参数必填") + return + } + + fullPath, safe := fc.checkPath(filePath, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + content, err := os.ReadFile(fullPath) + if err != nil { + utils.NotFound(c, "文件不存在") + return + } + + utils.Success(c, gin.H{ + "path": filePath, + "content": string(content), + }) +} + +func (fc *FileController) SaveFileContent(c *gin.Context) { + var req struct { + Path string `json:"path" binding:"required"` + Content string `json:"content"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + fullPath, safe := fc.checkPath(req.Path, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + os.MkdirAll(filepath.Dir(fullPath), 0755) + + if err := os.WriteFile(fullPath, []byte(req.Content), 0644); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "保存成功") +} + +func (fc *FileController) CreateFile(c *gin.Context) { + var req struct { + Path string `json:"path" binding:"required"` + IsDir bool `json:"isDir"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + fullPath, safe := fc.checkPath(req.Path, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if req.IsDir { + if err := os.MkdirAll(fullPath, 0755); err != nil { + utils.ServerError(c, err.Error()) + return + } + } else { + os.MkdirAll(filepath.Dir(fullPath), 0755) + if err := os.WriteFile(fullPath, []byte(""), 0644); err != nil { + utils.ServerError(c, err.Error()) + return + } + } + + utils.SuccessMsg(c, "创建成功") +} + +func (fc *FileController) DeleteFile(c *gin.Context) { + var req struct { + Path string `json:"path" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + fullPath, safe := fc.checkPath(req.Path, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if err := os.RemoveAll(fullPath); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "删除成功") +} + +func (fc *FileController) MoveFile(c *gin.Context) { + var req struct { + OldPath string `json:"oldPath" binding:"required"` + NewPath string `json:"newPath" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + oldFull, oldSafe := fc.checkPath(req.OldPath, false) + newFull, newSafe := fc.checkPath(req.NewPath, false) + + if !oldSafe || !newSafe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if oldFull == newFull { + utils.Success(c, nil) + return + } + + // 检查目标是否存在 + if _, err := os.Stat(newFull); err == nil { + utils.BadRequest(c, "目标已存在") + return + } + + // 确保目标目录存在 + os.MkdirAll(filepath.Dir(newFull), 0755) + + if err := os.Rename(oldFull, newFull); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, nil) +} + +func (fc *FileController) CopyFile(c *gin.Context) { + var req struct { + SourcePath string `json:"sourcePath" binding:"required"` + TargetPath string `json:"targetPath" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + sourceFull, sourceSafe := fc.checkPath(req.SourcePath, false) + targetFull, targetSafe := fc.checkPath(req.TargetPath, false) + + if !sourceSafe || !targetSafe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if sourceFull == targetFull { + utils.Success(c, nil) + return + } + + // Read content + content, err := os.ReadFile(sourceFull) + if err != nil { + utils.NotFound(c, "源文件不存在或无法读取") + return + } + + // 确保目标目录存在 + os.MkdirAll(filepath.Dir(targetFull), 0755) + + // 检查目标是否存在 + if _, err := os.Stat(targetFull); err == nil { + utils.BadRequest(c, "目标已存在") + return + } + + if err := os.WriteFile(targetFull, content, 0644); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, nil) +} + +func (fc *FileController) RenameFile(c *gin.Context) { + var req struct { + OldPath string `json:"oldPath" binding:"required"` + NewPath string `json:"newPath" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 校验:重命名禁止跨目录 + if filepath.Dir(filepath.Clean(req.OldPath)) != filepath.Dir(filepath.Clean(req.NewPath)) { + utils.BadRequest(c, "禁止跨目录重命名") + return + } + + oldFull, oldSafe := fc.checkPath(req.OldPath, false) + newFull, newSafe := fc.checkPath(req.NewPath, false) + + if !oldSafe || !newSafe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if oldFull == newFull { + utils.Success(c, nil) + return + } + + // 检查目标是否存在 + if _, err := os.Stat(newFull); err == nil { + utils.BadRequest(c, "文件已存在") + return + } + + if err := os.Rename(oldFull, newFull); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, nil) +} + +// UploadArchive 处理归档文件的上传和解压 +func (fc *FileController) UploadArchive(c *gin.Context) { + targetDir := c.PostForm("path") + + file, err := c.FormFile("file") + if err != nil { + utils.BadRequest(c, "请选择文件") + return + } + + // 检查文件类型 + ext := strings.ToLower(filepath.Ext(file.Filename)) + if ext != ".zip" && ext != ".tar" && ext != ".gz" && ext != ".tgz" { + utils.BadRequest(c, "仅支持 zip、tar、gz、tgz 格式") + return + } + + // 确定解压目标目录 + extractDir, safe := fc.checkPath(targetDir, true) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + os.MkdirAll(extractDir, 0755) + + // 保存临时文件 + // 安全修复:使用 filepath.Base 提取纯文件名,防止路径穿越攻击 + tempFile := filepath.Join(os.TempDir(), filepath.Base(file.Filename)) + if err := c.SaveUploadedFile(file, tempFile); err != nil { + utils.ServerError(c, "保存文件失败") + return + } + defer os.Remove(tempFile) + + // 解压文件 + var extractErr error + switch { + case ext == ".zip": + extractErr = extractZip(tempFile, extractDir) + case ext == ".tar": + extractErr = extractTar(tempFile, extractDir) + case ext == ".gz" || ext == ".tgz": + extractErr = extractTarGz(tempFile, extractDir) + } + + if extractErr != nil { + utils.ServerError(c, "解压失败: "+extractErr.Error()) + return + } + + utils.SuccessMsg(c, "导入成功") +} + +// UploadFiles 处理多个文件的上传 +func (fc *FileController) UploadFiles(c *gin.Context) { + targetDir := c.PostForm("path") + + // 确定目标目录 + destDir, safe := fc.checkPath(targetDir, true) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + os.MkdirAll(destDir, 0755) + + form, err := c.MultipartForm() + if err != nil { + utils.BadRequest(c, "请选择文件") + return + } + + files := form.File["files"] + paths := form.Value["paths"] // 相对路径数组,用于保持文件夹结构 + + if len(files) == 0 { + utils.BadRequest(c, "请选择文件") + return + } + + for i, file := range files { + // 获取相对路径(如果有) + // 安全修复:清理文件名 + relPath := filepath.Base(file.Filename) + if i < len(paths) && paths[i] != "" { + relPath = paths[i] + } + + // 构建完整路径 + fullPath, safe := fc.checkPath(filepath.Join(targetDir, relPath), false) + if !safe { + continue + } + + // 确保父目录存在 + os.MkdirAll(filepath.Dir(fullPath), 0755) + + // 保存文件 + if err := c.SaveUploadedFile(file, fullPath); err != nil { + utils.ServerError(c, "保存文件失败: "+err.Error()) + return + } + } + + utils.SuccessMsg(c, "上传成功") +} + +func (fc *FileController) DownloadFile(c *gin.Context) { + filePath := c.Query("path") + if filePath == "" { + utils.BadRequest(c, "path参数必填") + return + } + + fullPath, safe := fc.checkPath(filePath, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + info, err := os.Stat(fullPath) + if err != nil || info.IsDir() { + utils.NotFound(c, "文件不存在") + return + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", "attachment; filename="+filepath.Base(fullPath)) + c.Header("Content-Type", "application/octet-stream") + c.File(fullPath) +} + +func (fc *FileController) DownloadZip(c *gin.Context) { + paths := c.QueryArray("path") + if len(paths) == 0 || c.ContentType() == "application/json" { + var req struct { + Paths []string `json:"paths"` + } + if err := c.ShouldBindJSON(&req); err == nil && len(paths) == 0 { + paths = req.Paths + } + } + + if len(paths) == 0 { + utils.BadRequest(c, "path参数必填") + return + } + + validatedAbsPaths := make([]string, 0, len(paths)) + for _, path := range paths { + fullPath, safe := fc.checkPath(path, false) + if !safe { + utils.Forbidden(c, "访问被拒绝") + return + } + + if _, err := os.Stat(fullPath); err != nil { + utils.NotFound(c, "文件不存在") + return + } + + validatedAbsPaths = append(validatedAbsPaths, fullPath) + } + + fileName := "taskpool-export-" + time.Now().Format("20060102-150405") + ".zip" + if len(validatedAbsPaths) == 1 { + fileName = filepath.Base(validatedAbsPaths[0]) + ".zip" + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", "attachment; filename="+fileName) + c.Header("Content-Type", "application/zip") + + if err := utils.CreateZip(c.Writer, validatedAbsPaths); err != nil { + return + } +} diff --git a/internal/controllers/install_controller.go b/internal/controllers/install_controller.go new file mode 100644 index 0000000..0729bac --- /dev/null +++ b/internal/controllers/install_controller.go @@ -0,0 +1,110 @@ +package controllers + +import ( + "net/http" + + "github.com/engigu/taskpool/internal/services" + "github.com/gin-gonic/gin" +) + +type InstallController struct { + installService *services.InstallService +} + +func NewInstallController() *InstallController { + return &InstallController{ + installService: services.NewInstallService(), + } +} + +// GetInstallStatus 获取安装状态 +// @Summary 获取安装状态 +// @Description 检查系统是否已完成安装 +// @Tags 安装 +// @Produce json +// @Success 200 {object} services.InstallStatus +// @Router /api/v1/install/status [get] +func (c *InstallController) GetInstallStatus(ctx *gin.Context) { + status, err := c.installService.CheckInstallStatus() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, status) +} + +// Install 执行安装 +// @Summary 执行安装 +// @Description 初始化系统配置和管理员账号 +// @Tags 安装 +// @Accept json +// @Produce json +// @Param request body services.InstallRequest true "安装请求" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/install [post] +func (c *InstallController) Install(ctx *gin.Context) { + // 先检查是否已安装 + status, err := c.installService.CheckInstallStatus() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "检查安装状态失败"}) + return + } + + if status.Installed { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "系统已安装,无法重复安装"}) + return + } + + var req services.InstallRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()}) + return + } + + // 验证必填字段 + if req.AdminUsername == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "管理员用户名不能为空"}) + return + } + if req.AdminPassword == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "管理员密码不能为空"}) + return + } + if len(req.AdminPassword) < 6 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "管理员密码至少6位"}) + return + } + + // MySQL 必填验证 + if req.DBType == "mysql" { + if req.DBHost == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "MySQL 主机不能为空"}) + return + } + if req.DBName == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "MySQL 数据库名不能为空"}) + return + } + } + + // Redis 启用时的验证 + if req.RedisEnabled { + if req.RedisHost == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Redis 主机不能为空"}) + return + } + } + + // 执行安装 + if err := c.installService.Install(&req); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "安装失败: " + err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "安装成功", + "admin_username": req.AdminUsername, + }) +} diff --git a/internal/controllers/interconnect_controller.go b/internal/controllers/interconnect_controller.go new file mode 100644 index 0000000..382d7ca --- /dev/null +++ b/internal/controllers/interconnect_controller.go @@ -0,0 +1,532 @@ +package controllers + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/tunnel" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type InterconnectController struct { + interconnectService *services.InterconnectService + httpClient *http.Client +} + +func NewInterconnectController(interconnectService *services.InterconnectService) *InterconnectController { + return &InterconnectController{ + interconnectService: interconnectService, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// GetNodes 获取互联节点列表 +func (ic *InterconnectController) GetNodes(c *gin.Context) { + nodes, err := ic.interconnectService.GetNodes() + if err != nil { + utils.ServerError(c, "获取互联节点失败") + return + } + utils.Success(c, nodes) +} + +// CreateNode 创建互联节点 +func (ic *InterconnectController) CreateNode(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + URL string `json:"url"` + Token string `json:"token" binding:"required"` + Remark string `json:"remark"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + node, err := ic.interconnectService.CreateNode(req.Name, req.URL, req.Token, req.Remark) + if err != nil { + utils.ServerError(c, "创建互联节点失败") + return + } + + utils.Success(c, node) +} + +// UpdateNode 更新互联节点 +func (ic *InterconnectController) UpdateNode(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的节点ID") + return + } + + var req struct { + Name string `json:"name" binding:"required"` + URL string `json:"url"` + Token string `json:"token" binding:"required"` + Remark string `json:"remark"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + node, err := ic.interconnectService.UpdateNode(id, req.Name, req.URL, req.Token, req.Remark) + if err != nil { + utils.ServerError(c, "更新互联节点失败") + return + } + + utils.Success(c, node) +} + +// DeleteNode 删除互联节点 +func (ic *InterconnectController) DeleteNode(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的节点ID") + return + } + + err := ic.interconnectService.DeleteNode(id) + if err != nil { + utils.ServerError(c, "删除互联节点失败") + return + } + + utils.Success(c, nil) +} + +// GetNodeStatus 获取单个子节点的状态 +func (ic *InterconnectController) GetNodeStatus(c *gin.Context) { + id := c.Param("id") + node, err := ic.interconnectService.GetNodeByID(id) + if err != nil { + utils.NotFound(c, "节点不存在") + return + } + + // 针对反向隧道节点状态检测的特判 + if strings.HasPrefix(node.URL, "tunnel://") { + sess := tunnel.GetSession(node.ID) + if sess == nil { + c.JSON(200, gin.H{"code": 500, "msg": "节点离线或反向隧道未建立", "data": nil}) + return + } + + // 使用当前 Yamux Session 的虚拟底层连接进行拨号 + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return sess.Session.Open() + }, + } + client := &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } + + req, err := http.NewRequest("GET", "http://tunnel.local/api/v1/monitor", nil) + if err != nil { + utils.ServerError(c, "构建检测请求失败") + return + } + req.Header.Set("Authorization", "Bearer "+node.Token) + + resp, err := client.Do(req) + if err != nil { + c.JSON(200, gin.H{"code": 500, "msg": "与子节点逆向连接通讯失败", "data": nil}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + c.JSON(200, gin.H{"code": 500, "msg": "子节点检测异常", "data": string(body)}) + return + } + + var jsonResp map[string]interface{} + if err := json.Unmarshal(body, &jsonResp); err != nil { + utils.ServerError(c, "解析节点检测数据失败") + return + } + + if dataMap, ok := jsonResp["data"].(map[string]interface{}); ok { + dataMap["tunnel_connected"] = true + dataMap["tunnel_url"] = node.URL + if hostMap, ok := dataMap["host"].(map[string]interface{}); ok { + hostMap["tx_bytes"] = node.Metrics.TxBytes + hostMap["rx_bytes"] = node.Metrics.RxBytes + } + } + + utils.Success(c, jsonResp["data"]) + return + } + + apiURL := strings.TrimRight(node.URL, "/") + "/api/v1/monitor" + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + utils.ServerError(c, "构建请求失败") + return + } + req.Header.Set("Authorization", "Bearer "+node.Token) + + resp, err := ic.httpClient.Do(req) + if err != nil { + c.JSON(200, gin.H{"code": 500, "msg": "节点离线或网络不可达", "data": nil}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + c.JSON(200, gin.H{"code": 500, "msg": "节点返回异常", "data": string(body)}) + return + } + + var jsonResp map[string]interface{} + if err := json.Unmarshal(body, &jsonResp); err != nil { + utils.ServerError(c, "解析节点响应失败") + return + } + + utils.Success(c, jsonResp["data"]) +} + +// SyncScript 将脚本同步到指定的节点列表 +func (ic *InterconnectController) SyncScript(c *gin.Context) { + var req struct { + NodeIDs []string `json:"node_ids" binding:"required"` + Filename string `json:"filename" binding:"required"` + Content string `json:"content" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + results := make([]map[string]interface{}, 0) + + for _, nodeID := range req.NodeIDs { + node, err := ic.interconnectService.GetNodeByID(nodeID) + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"}) + continue + } + + client, apiURL, err := ic.getClientAndURL(node, "/api/v1/scripts/save") + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"}) + continue + } + + payload := map[string]interface{}{ + "filename": req.Filename, + "content": req.Content, + } + payloadBytes, _ := json.Marshal(payload) + + httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"}) + continue + } + httpReq.Header.Set("Authorization", "Bearer "+node.Token) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + if err != nil || resp.StatusCode != 200 { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"}) + } else { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"}) + } + if resp != nil { + resp.Body.Close() + } + } + + utils.Success(c, results) +} + +// SyncEnv 将环境变量同步到指定的节点列表 +func (ic *InterconnectController) SyncEnv(c *gin.Context) { + var req struct { + NodeIDs []string `json:"node_ids" binding:"required"` + Envs []struct{ ID string `json:"id"` } `json:"envs" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + var envIDs []string + for _, e := range req.Envs { + envIDs = append(envIDs, e.ID) + } + + dataService := services.NewDataService() + exportData := dataService.ExportBusinessData(nil, envIDs) + + results := make([]map[string]interface{}, 0) + + for _, nodeID := range req.NodeIDs { + node, err := ic.interconnectService.GetNodeByID(nodeID) + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"}) + continue + } + + client, apiURL, err := ic.getClientAndURL(node, "/api/v1/system/import") + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"}) + continue + } + + payloadBytes, _ := json.Marshal(exportData) + + httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"}) + continue + } + httpReq.Header.Set("Authorization", "Bearer "+node.Token) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + if err != nil || resp.StatusCode != 200 { + msg := "同步失败" + if err != nil { + msg = err.Error() + } + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": msg}) + } else { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"}) + } + if resp != nil { + resp.Body.Close() + } + } + + utils.Success(c, results) +} + +// SyncTask 将任务同步到指定的节点列表 +func (ic *InterconnectController) SyncTask(c *gin.Context) { + var req struct { + NodeIDs []string `json:"node_ids" binding:"required"` + Tasks []struct{ ID string `json:"id"` } `json:"tasks" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + var taskIDs []string + for _, t := range req.Tasks { + taskIDs = append(taskIDs, t.ID) + } + + dataService := services.NewDataService() + exportData := dataService.ExportBusinessData(taskIDs, nil) + + results := make([]map[string]interface{}, 0) + + for _, nodeID := range req.NodeIDs { + node, err := ic.interconnectService.GetNodeByID(nodeID) + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"}) + continue + } + + client, apiURL, err := ic.getClientAndURL(node, "/api/v1/system/import") + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"}) + continue + } + + payloadBytes, _ := json.Marshal(exportData) + + httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"}) + continue + } + httpReq.Header.Set("Authorization", "Bearer "+node.Token) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(httpReq) + if err != nil || resp.StatusCode != 200 { + msg := "同步失败" + if err != nil { + msg = err.Error() + } + results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": msg}) + } else { + results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"}) + } + if resp != nil { + resp.Body.Close() + } + } + + utils.Success(c, results) +} + +// HandleTunnel 接受子节点 WebSocket 连接请求 +func (ic *InterconnectController) HandleTunnel(c *gin.Context) { + tunnel.HandleTunnel(c) +} + +// ProxyRequest 代理转发请求至目标节点 +func (ic *InterconnectController) ProxyRequest(c *gin.Context) { + nodeID := c.Param("node_id") + path := c.Param("path") + if nodeID == "" { + utils.BadRequest(c, "Node ID required") + return + } + + node, err := ic.interconnectService.GetNodeByID(nodeID) + if err != nil { + utils.NotFound(c, "Node not found") + return + } + + if strings.HasPrefix(node.URL, "tunnel://") { + // 走 WebSocket 逆向隧道 (基于 Yamux 流式多路复用) + err := tunnel.ProxyHTTP(nodeID, c, path) + if err != nil { + utils.ServerError(c, "Tunnel request failed: "+err.Error()) + } + return + } + + // 走普通 HTTP 直连 + // Construct the target URL + targetURL := strings.TrimRight(node.URL, "/") + path + if c.Request.URL.RawQuery != "" { + targetURL += "?" + c.Request.URL.RawQuery + } + + req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body) + if err != nil { + utils.ServerError(c, "Failed to create proxy request") + return + } + + // Copy headers + req.Header = c.Request.Header.Clone() + + // If the node token exists, append it as Bearer Auth + if node.Token != "" { + req.Header.Set("Authorization", "Bearer "+node.Token) + } + + resp, err := ic.httpClient.Do(req) + if err != nil { + utils.ServerError(c, "Failed to connect to target node: "+err.Error()) + return + } + defer resp.Body.Close() + + for k, v := range resp.Header { + for _, vv := range v { + c.Writer.Header().Add(k, vv) + } + } + c.Status(resp.StatusCode) + io.Copy(c.Writer, resp.Body) +} + +// ReportMonitorData 接收子节点上报的监控数据 +func (ic *InterconnectController) ReportMonitorData(c *gin.Context) { + var req models.NodeMetrics + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(401, gin.H{"error": "missing authorization"}) + return + } + tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + + node, err := ic.interconnectService.GetNodeByToken(tokenStr) + if err != nil { + c.JSON(401, gin.H{"error": "invalid token"}) + return + } + + err = ic.interconnectService.UpdateNodeMonitorData(node.ID, req) + if err != nil { + utils.ServerError(c, "更新节点数据失败") + return + } + utils.Success(c, gin.H{ + "tunnel_url": node.URL, + }) +} + +// GetChildStatus 获取本机作为子节点的连接状态 +func (ic *InterconnectController) GetChildStatus(c *gin.Context) { + settingsSvc := services.NewSettingsService() + parentURL := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentURL) + parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken) + + connected := tunnel.IsTunnelConnected() + tunnelURL := tunnel.GetLocalTunnelURL() + + utils.Success(c, gin.H{ + "parent_url": parentURL, + "parent_token": parentToken, + "connected": connected, + "tunnel_url": tunnelURL, + "tx_bytes": tunnel.GetTxBytes(), + "rx_bytes": tunnel.GetRxBytes(), + }) +} + +// getClientAndURL 辅助方法:根据节点类型决定走直连还是隧道,并返回对应的 Client 和完整 URL +func (ic *InterconnectController) getClientAndURL(node *models.InterconnectNode, path string) (*http.Client, string, error) { + if strings.HasPrefix(node.URL, "tunnel://") { + sess := tunnel.GetSession(node.ID) + if sess == nil { + return nil, "", net.ErrClosed + } + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return sess.Session.Open() + }, + } + client := &http.Client{ + Transport: transport, + Timeout: 10 * time.Second, + } + return client, "http://tunnel.local" + path, nil + } + + targetURL := strings.TrimRight(node.URL, "/") + path + return ic.httpClient, targetURL, nil +} diff --git a/internal/controllers/log_controller.go b/internal/controllers/log_controller.go new file mode 100644 index 0000000..bc32dd2 --- /dev/null +++ b/internal/controllers/log_controller.go @@ -0,0 +1,169 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type LogController struct{} + +func NewLogController() *LogController { + return &LogController{} +} + +// GetLogs 获取任务日志列表 +// @Summary 获取任务日志列表 +// @Description 分页获取任务日志列表,支持按任务 ID、任务名称、状态筛选 +// @Tags 日志管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param task_id query string false "任务 ID" +// @Param task_name query string false "任务名称" +// @Param status query string false "状态" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} utils.Response{data=utils.PaginationData{data=[]vo.TaskLogVO}} +// @Router /logs [get] +func (lc *LogController) GetLogs(c *gin.Context) { + p := utils.ParsePagination(c) + taskID := c.DefaultQuery("task_id", "") + taskName := c.DefaultQuery("task_name", "") + status := c.DefaultQuery("status", "") + + var logs []models.TaskLog + var total int64 + + query := database.DB.Model(&models.TaskLog{}) + if taskID != "" { + query = query.Where("task_id = ?", taskID) + } + if status != "" { + query = query.Where("status = ?", status) + } + + // 按任务名称过滤 + if taskName != "" { + var taskIDs []string + database.DB.Model(&models.Task{}).Where("name LIKE ?", "%"+taskName+"%").Pluck("id", &taskIDs) + if len(taskIDs) > 0 { + query = query.Where("task_id IN ?", taskIDs) + } else { + utils.PaginatedResponse(c, []vo.TaskLogVO{}, 0, p) + return + } + } + + query.Count(&total) + query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&logs) + + taskIDList := make([]string, 0) + for _, log := range logs { + taskIDList = append(taskIDList, log.TaskID) + } + + var tasks []models.Task + database.DB.Where("id IN ?", taskIDList).Find(&tasks) + taskMap := make(map[string]models.Task) + for _, t := range tasks { + taskMap[t.ID] = t + } + + result := make([]vo.TaskLogVO, len(logs)) + for i, log := range logs { + task := taskMap[log.TaskID] + taskType := task.Type + if taskType == "" { + taskType = "task" + } + result[i] = vo.TaskLogVO{ + ID: log.ID, + TaskID: log.TaskID, + TaskName: task.Name, + TaskType: taskType, + AgentID: log.AgentID, + Command: string(log.Command), + Status: log.Status, + Duration: log.Duration, + StartTime: log.StartTime, + EndTime: log.EndTime, + CreatedAt: log.CreatedAt, + } + } + + utils.PaginatedResponse(c, result, total, p) +} + +// GetLogDetail 获取日志详情 +// @Summary 获取日志详情 +// @Description 根据 ID 获取任务日志详细内容(包含输出) +// @Tags 日志管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "日志ID" +// @Success 200 {object} utils.Response{data=vo.TaskLogVO} +// @Failure 404 {object} utils.Response +// @Router /logs/{id} [get] +func (lc *LogController) GetLogDetail(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的日志ID") + return + } + + var log models.TaskLog + res := database.DB.Where("id = ?", id).Limit(1).Find(&log) + if res.Error != nil || res.RowsAffected == 0 { + utils.NotFound(c, "日志不存在") + return + } + + utils.Success(c, vo.ToTaskLogVO(&log)) +} + +// ClearLogs 清空日志 +func (lc *LogController) ClearLogs(c *gin.Context) { + var req struct { + TaskID *string `json:"task_id"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + query := database.DB.Model(&models.TaskLog{}) + if req.TaskID != nil && *req.TaskID != "" { + query = query.Where("task_id = ?", *req.TaskID) + } else { + query = query.Where("1 = 1") // Allow delete all without GORM safety block + } + + if err := query.Delete(&models.TaskLog{}).Error; err != nil { + utils.ServerError(c, "清空日志失败") + return + } + + utils.SuccessMsg(c, "日志清空成功") +} + +// DeleteLog 删除日志 +func (lc *LogController) DeleteLog(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的日志ID") + return + } + + if err := database.DB.Where("id = ?", id).Delete(&models.TaskLog{}).Error; err != nil { + utils.ServerError(c, "删除日志失败") + return + } + + utils.SuccessMsg(c, "日志已删除") +} diff --git a/internal/controllers/log_sse_controller.go b/internal/controllers/log_sse_controller.go new file mode 100644 index 0000000..d47ea41 --- /dev/null +++ b/internal/controllers/log_sse_controller.go @@ -0,0 +1,99 @@ +package controllers + +import ( + "fmt" + "io" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type LogSSEController struct{} + +func NewLogSSEController() *LogSSEController { + return &LogSSEController{} +} + +func (lc *LogSSEController) StreamLog(c *gin.Context) { + logIDStr := c.Query("log_id") + if logIDStr == "" { + c.JSON(400, gin.H{"error": "log_id is required"}) + return + } + + logID := logIDStr + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Transfer-Encoding", "chunked") + // c.Header("Access-Control-Allow-Origin", "*") + + // 1. 检查数据库中是否已结束 + var taskLog models.TaskLog + res := database.DB.Where("id = ?", logID).Limit(1).Find(&taskLog) + if res.Error == nil && res.RowsAffected > 0 { + if taskLog.Status != "running" { + // 已结束,读取库内日志 + content, err := utils.DecompressFromBase64(string(taskLog.Output)) + if err != nil { + c.SSEvent("message", gin.H{"text": "解压日志失败: " + err.Error()}) + c.Writer.Flush() + return + } + c.SSEvent("message", gin.H{"text": content}) + c.Writer.Flush() + return + } + } + + // 2. 未结束或未找到记录,尝试从 TinyLogManager 获取 + tl := tasks.GetActiveLog(logID) + if tl == nil { + c.SSEvent("message", gin.H{"text": "未找到正在运行的任务日志"}) + c.Writer.Flush() + return + } + + // 发送系统提示 + c.SSEvent("message", gin.H{"text": fmt.Sprintf("[System] 连接成功,正在监听日志... (LogID: %s)\n", logID)}) + c.Writer.Flush() + + // 发送最后 100 行 + lastLines, err := tl.ReadLastLines(100) + if err == nil && len(lastLines) > 0 { + c.SSEvent("message", gin.H{"text": string(lastLines)}) + c.Writer.Flush() + } + + // 订阅实时更新 + sub := tl.Subscribe() + defer tl.Unsubscribe(sub) + + // 推送更新 + c.Stream(func(w io.Writer) bool { + select { + case data, ok := <-sub: + if !ok { + // 任务结束,尝试刷新最后一次库内完整内容 + var finalLog models.TaskLog + res := database.DB.Where("id = ?", logID).Limit(1).Find(&finalLog) + if res.Error == nil && res.RowsAffected > 0 { + content, _ := utils.DecompressFromBase64(string(finalLog.Output)) + if content != "" { + c.SSEvent("message", gin.H{"text": "\n--- 任务已结束 ---\n"}) + } + } + return false + } + c.SSEvent("message", gin.H{"text": string(data)}) + return true + case <-c.Request.Context().Done(): + return false + } + }) +} diff --git a/internal/controllers/mise_controller.go b/internal/controllers/mise_controller.go new file mode 100644 index 0000000..f56fb1b --- /dev/null +++ b/internal/controllers/mise_controller.go @@ -0,0 +1,165 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type MiseController struct { + service *services.MiseService +} + +func NewMiseController(service *services.MiseService) *MiseController { + return &MiseController{ + service: service, + } +} + +// List 获取语言列表 +func (c *MiseController) List(ctx *gin.Context) { + langs, err := c.service.List() + if err != nil { + utils.ServerError(ctx, "获取语言列表失败: "+err.Error()) + return + } + utils.Success(ctx, langs) +} + +// Sync 同步本地环境到数据库 +func (c *MiseController) Sync(ctx *gin.Context) { + if err := c.service.Sync(); err != nil { + utils.ServerError(ctx, "同步本地环境失败: "+err.Error()) + return + } + utils.Success(ctx, nil) +} + +// Plugins 获取可用插件列表 +func (c *MiseController) Plugins(ctx *gin.Context) { + plugins, err := c.service.Plugins() + if err != nil { + utils.ServerError(ctx, "获取插件列表失败: "+err.Error()) + return + } + utils.Success(ctx, plugins) +} + +// Versions 获取指定插件的可用版本列表 +func (c *MiseController) Versions(ctx *gin.Context) { + plugin := ctx.Query("plugin") + if plugin == "" { + utils.BadRequest(ctx, "参数 plugin 不能为空") + return + } + versions, err := c.service.Versions(plugin) + if err != nil { + utils.ServerError(ctx, "获取版本列表失败: "+err.Error()) + return + } + utils.Success(ctx, versions) +} + +// VerifyCommand 获取验证命令 +func (c *MiseController) VerifyCommand(ctx *gin.Context) { + plugin := ctx.Query("plugin") + version := ctx.Query("version") + if plugin == "" { + utils.BadRequest(ctx, "参数 plugin 不能为空") + return + } + cmd, err := c.service.GetVerifyCommand(plugin, version) + if err != nil { + utils.ServerError(ctx, "获取验证命令失败: "+err.Error()) + return + } + utils.Success(ctx, gin.H{"command": cmd}) +} + +// UseGlobal 设置全局默认版本 +func (c *MiseController) UseGlobal(ctx *gin.Context) { + var req struct { + Plugin string `json:"plugin"` + Version string `json:"version"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + if req.Plugin == "" || req.Version == "" { + utils.BadRequest(ctx, "参数 plugin 和 version 不能为空") + return + } + if err := c.service.UseGlobal(req.Plugin, req.Version); err != nil { + utils.ServerError(ctx, "设置全局版本失败: "+err.Error()) + return + } + utils.Success(ctx, nil) +} + +// UnsetGlobal 取消全局默认版本 +func (c *MiseController) UnsetGlobal(ctx *gin.Context) { + var req struct { + Plugin string `json:"plugin"` + Version string `json:"version"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + if req.Plugin == "" { + utils.BadRequest(ctx, "参数 plugin 不能为空") + return + } + if err := c.service.UnsetGlobal(req.Plugin, req.Version); err != nil { + utils.ServerError(ctx, "取消全局版本失败: "+err.Error()) + return + } + utils.Success(ctx, nil) +} + +// Envs 获取全局环境变量 +func (c *MiseController) Envs(ctx *gin.Context) { + envs, err := c.service.Envs() + if err != nil { + utils.ServerError(ctx, "获取全局环境变量失败: "+err.Error()) + return + } + utils.Success(ctx, envs) +} + +// SetEnv 设置全局环境变量 +func (c *MiseController) SetEnv(ctx *gin.Context) { + var req struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + if req.Key == "" { + utils.BadRequest(ctx, "参数 key 不能为空") + return + } + if err := c.service.SetEnv(req.Key, req.Value); err != nil { + utils.ServerError(ctx, "设置环境变量失败: "+err.Error()) + return + } + utils.Success(ctx, nil) +} + +// UnsetEnv 取消全局环境变量 +func (c *MiseController) UnsetEnv(ctx *gin.Context) { + key := ctx.Query("key") + if key == "" { + utils.BadRequest(ctx, "参数 key 不能为空") + return + } + if err := c.service.UnsetEnv(key); err != nil { + utils.ServerError(ctx, "取消环境变量失败: "+err.Error()) + return + } + utils.Success(ctx, nil) +} diff --git a/internal/controllers/monitor_controller.go b/internal/controllers/monitor_controller.go new file mode 100644 index 0000000..3d06e05 --- /dev/null +++ b/internal/controllers/monitor_controller.go @@ -0,0 +1,134 @@ +package controllers + +import ( + "net/http" + "runtime" + "time" + + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type MonitorController struct { + executorService *tasks.ExecutorService +} + +func NewMonitorController(executorService *tasks.ExecutorService) *MonitorController { + return &MonitorController{ + executorService: executorService, + } +} + +var monitorUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // 开发环境允许所有跨域,生产环境可根据配置限制 + }, +} + +// GetSystemMonitor 获取系统和内存监控信息 (HTTP) +func (mc *MonitorController) GetSystemMonitor(c *gin.Context) { + data := mc.getMonitorData() + utils.Success(c, data) +} + +// MonitorSSE Server-Sent Events 获取系统监控数据 +func (mc *MonitorController) MonitorSSE(c *gin.Context) { + // 设置 SSE 响应头 + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + + // 初始发送一次数据 + if err := mc.sendMonitorDataSSE(c); err != nil { + return + } + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := mc.sendMonitorDataSSE(c); err != nil { + return // 客户端断开连接或发送失败 + } + case <-c.Request.Context().Done(): + return // 连接已断开,立即退出 + } + } +} + +func (mc *MonitorController) sendMonitorDataSSE(c *gin.Context) error { + data := mc.getMonitorData() + // 使用 Gin 提供的 SSE 方法 + c.SSEvent("message", gin.H{ + "code": 200, + "data": data, + "msg": "success", + }) + c.Writer.Flush() + return nil +} + +func (mc *MonitorController) getMonitorData() gin.H { + rt := services.GetMonitorService().GetRuntimeMetrics() + m := rt.MemStats + + // 调用统一的监控服务获取物理机指标 + metrics := services.GetMonitorService().GetHostMetrics() + + return gin.H{ + "env": gin.H{ + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "go_version": runtime.Version(), + "num_cpu": runtime.NumCPU(), + "goroutines": rt.NumGoroutine, + }, + "host": gin.H{ + "cpu_percent": metrics.CPUPercent, + "mem_total": metrics.VMem.Total, + "mem_used": metrics.VMem.Used, + "mem_percent": metrics.VMem.UsedPercent, + "disk_total": metrics.DiskUsage.Total, + "disk_used": metrics.DiskUsage.Used, + "disk_percent": metrics.DiskUsage.UsedPercent, + "uptime": metrics.HostInfo.Uptime, + "platform": metrics.HostInfo.Platform + " " + metrics.HostInfo.PlatformVersion, + }, + "mem": gin.H{ + "alloc": m.Alloc, + "total_alloc": m.TotalAlloc, + "sys": m.Sys, + "lookups": m.Lookups, + "mallocs": m.Mallocs, + "frees": m.Frees, + }, + "heap": gin.H{ + "heap_alloc": m.HeapAlloc, + "heap_sys": m.HeapSys, + "heap_idle": m.HeapIdle, + "heap_inuse": m.HeapInuse, + "heap_released": m.HeapReleased, + "heap_objects": m.HeapObjects, + }, + "gc": gin.H{ + "next_gc": m.NextGC, + "last_gc": m.LastGC, + "pause_total_ns": m.PauseTotalNs, + "num_gc": m.NumGC, + }, + "scheduler": gin.H{ + "scheduled": mc.executorService.GetScheduledCount(), + "running": mc.executorService.GetRunningCount(), + "queue_size": mc.executorService.GetScheduler().GetQueueSize(), + "worker_count": mc.executorService.GetScheduler().GetConfig().WorkerCount, + "workers": mc.executorService.GetScheduler().GetWorkerStatuses(), + }, + } +} diff --git a/internal/controllers/notification_controller.go b/internal/controllers/notification_controller.go new file mode 100644 index 0000000..96eda5f --- /dev/null +++ b/internal/controllers/notification_controller.go @@ -0,0 +1,194 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + "github.com/gin-gonic/gin" +) + +type NotificationController struct { + notifyService *services.NotificationService +} + +func NewNotificationController() *NotificationController { + return &NotificationController{ + notifyService: services.NewNotificationService(), + } +} + +// GetChannelTypes 获取支持的渠道类型 +func (nc *NotificationController) GetChannelTypes(c *gin.Context) { + utils.Success(c, gin.H{ + "channel_types": services.SupportedChannelTypes, + "event_types": services.SupportedEvents, + }) +} + +// GetChannels 获取所有渠道 +func (nc *NotificationController) GetChannels(c *gin.Context) { + channels := nc.notifyService.GetChannels() + utils.Success(c, channels) +} + +// SaveChannel 保存/更新渠道 +func (nc *NotificationController) SaveChannel(c *gin.Context) { + var req services.NotifyChannel + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.Name == "" || req.Type == "" { + utils.BadRequest(c, "渠道名称和类型不能为空") + return + } + + if err := nc.notifyService.SaveChannel(req); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "保存成功") +} + +// DeleteChannel 删除渠道 +func (nc *NotificationController) DeleteChannel(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "缺少渠道ID") + return + } + + if err := nc.notifyService.DeleteChannel(id); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "删除成功") +} + +// TestChannel 测试渠道 +func (nc *NotificationController) TestChannel(c *gin.Context) { + var req services.NotifyChannel + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + result := nc.notifyService.SendToChannel(req, &services.NotifyMessage{ + Title: "🔔 任务池测试通知", + Text: "如果你看到这条消息,说明通知渠道配置正确!", + }) + + utils.Success(c, result) +} + +// GetBindings 获取事件绑定列表 +func (nc *NotificationController) GetBindings(c *gin.Context) { + bindings := nc.notifyService.GetBindings() + utils.Success(c, bindings) +} + +// SaveBinding 保存事件绑定 +func (nc *NotificationController) SaveBinding(c *gin.Context) { + var req struct { + ID string `json:"id"` + Type string `json:"type"` + Event string `json:"event"` + WayID string `json:"way_id"` + DataID string `json:"data_id"` + Extra models.BigText `json:"extra"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.Type == "" || req.Event == "" || req.WayID == "" { + utils.BadRequest(c, "类型、事件和渠道ID不能为空") + return + } + + binding := &models.NotifyBinding{ + ID: req.ID, + Type: req.Type, + Event: req.Event, + WayID: req.WayID, + DataID: req.DataID, + Extra: req.Extra, + } + + if err := nc.notifyService.SaveBinding(binding); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.Success(c, binding) +} + +// DeleteBinding 删除事件绑定 +func (nc *NotificationController) DeleteBinding(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "缺少绑定ID") + return + } + + if err := nc.notifyService.DeleteBinding(id); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "删除成功") +} + +// BatchSaveBindings 批量保存事件绑定 +func (nc *NotificationController) BatchSaveBindings(c *gin.Context) { + var req struct { + Type string `json:"type"` + DataID string `json:"data_id"` + Bindings []models.NotifyBinding `json:"bindings"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.Type == "" { + utils.BadRequest(c, "类型不能为空") + return + } + + if err := nc.notifyService.BatchSaveBindings(req.Type, req.DataID, req.Bindings); err != nil { + utils.ServerError(c, err.Error()) + return + } + + utils.SuccessMsg(c, "保存成功") +} + +// SendNotification API 发送通知(供脚本调用) +func (nc *NotificationController) SendNotification(c *gin.Context) { + var req struct { + ChannelID string `json:"channel_id"` + Title string `json:"title"` + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if req.ChannelID == "" || req.Title == "" { + utils.BadRequest(c, "channel_id 和 title 不能为空") + return + } + + result := nc.notifyService.SendByChannelID(req.ChannelID, &services.NotifyMessage{ + Title: req.Title, + Text: req.Text, + }) + + utils.Success(c, result) +} diff --git a/internal/controllers/script_controller.go b/internal/controllers/script_controller.go new file mode 100644 index 0000000..282f87b --- /dev/null +++ b/internal/controllers/script_controller.go @@ -0,0 +1,155 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +type ScriptController struct { + scriptService *services.ScriptService +} + +func NewScriptController(scriptService *services.ScriptService) *ScriptController { + return &ScriptController{scriptService: scriptService} +} + +// CreateScript 创建脚本 +// @Summary 创建脚本 +// @Description 创建一个新的脚本 +// @Tags 脚本管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object true "脚本信息" +// @Success 200 {object} utils.Response{data=vo.ScriptVO} +// @Router /scripts [post] +func (sc *ScriptController) CreateScript(c *gin.Context) { + userID := c.GetString("userID") + + var req struct { + Name string `json:"name" binding:"required"` + Content string `json:"content" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + script := sc.scriptService.CreateScript(req.Name, req.Content, userID) + utils.Success(c, vo.ToScriptVO(script)) +} + +// GetScripts 获取脚本列表 +// @Summary 获取脚本列表 +// @Description 获取当前用户的所有脚本(内容字段为空) +// @Tags 脚本管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=[]vo.ScriptVO} +// @Router /scripts [get] +func (sc *ScriptController) GetScripts(c *gin.Context) { + userID := c.GetString("userID") + scripts := sc.scriptService.GetScriptsByUserID(userID) + vos := vo.ToScriptVOListFromModels(scripts) + for i := range vos { + vos[i].Content = "" // 列表不返回内容 + } + utils.Success(c, vos) +} + +// GetScript 获取脚本详情 +// @Summary 获取脚本详情 +// @Description 根据 ID 获取脚本详情 +// @Tags 脚本管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "脚本ID" +// @Success 200 {object} utils.Response{data=vo.ScriptVO} +// @Failure 404 {object} utils.Response +// @Router /scripts/{id} [get] +func (sc *ScriptController) GetScript(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的脚本ID") + return + } + + script := sc.scriptService.GetScriptByID(id) + if script == nil { + utils.NotFound(c, "脚本不存在") + return + } + + utils.Success(c, vo.ToScriptVO(script)) +} + +// UpdateScript 更新脚本 +// @Summary 更新脚本 +// @Description 根据 ID 更新脚本信息 +// @Tags 脚本管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "脚本ID" +// @Param body body object true "脚本更新信息" +// @Success 200 {object} utils.Response{data=vo.ScriptVO} +// @Failure 404 {object} utils.Response +// @Router /scripts/{id} [put] +func (sc *ScriptController) UpdateScript(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的脚本ID") + return + } + + var req struct { + Name string `json:"name"` + Content string `json:"content"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + script := sc.scriptService.UpdateScript(id, req.Name, req.Content) + if script == nil { + utils.NotFound(c, "脚本不存在") + return + } + + utils.Success(c, vo.ToScriptVO(script)) +} + +// DeleteScript 删除脚本 +// @Summary 删除脚本 +// @Description 根据 ID 删除脚本 +// @Tags 脚本管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "脚本ID" +// @Success 200 {object} utils.Response +// @Failure 404 {object} utils.Response +// @Router /scripts/{id} [delete] +func (sc *ScriptController) DeleteScript(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的脚本ID") + return + } + + success := sc.scriptService.DeleteScript(id) + if !success { + utils.NotFound(c, "脚本不存在") + return + } + + utils.SuccessMsg(c, "删除成功") +} diff --git a/internal/controllers/settings_controller.go b/internal/controllers/settings_controller.go new file mode 100644 index 0000000..92b3989 --- /dev/null +++ b/internal/controllers/settings_controller.go @@ -0,0 +1,624 @@ +package controllers + +import ( + "path/filepath" + "runtime" + "strconv" + + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/tunnel" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/shirou/gopsutil/v3/process" +) + +type SettingsController struct { + userService *services.UserService + settingsService *services.SettingsService + loginLogService *services.LoginLogService + backupService *services.BackupService + executorService *tasks.ExecutorService +} + +func NewSettingsController(userService *services.UserService, loginLogService *services.LoginLogService, executorService *tasks.ExecutorService) *SettingsController { + return &SettingsController{ + userService: userService, + settingsService: services.NewSettingsService(), + loginLogService: loginLogService, + backupService: services.NewBackupService(), + executorService: executorService, + } +} + +// ChangePassword 修改密码及账号信息 +func (sc *SettingsController) ChangePassword(c *gin.Context) { + // 演示模式下禁止修改 + if constant.DemoMode { + utils.BadRequest(c, "演示模式下不能修改账号或密码") + return + } + + var req struct { + OldUsername string `json:"old_username"` + Username string `json:"username"` + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + userID := c.GetString("userID") + var user *models.User + res := database.DB.Where("id = ?", userID).Limit(1).Find(&user) + if res.Error != nil || res.RowsAffected == 0 { + utils.NotFound(c, "用户不存在") + return + } + + // 统一校验原账密 + if req.OldUsername != "" && req.OldUsername != user.Username { + utils.BadRequest(c, "原账号不正确") + return + } + + if !sc.userService.AuthenticateUser(user.Username, req.OldPassword) { + utils.BadRequest(c, "原密码错误") + return + } + + var updated bool + var logoutRequired bool + + // 1. 处理用户名修改 + if req.Username != "" && req.Username != user.Username { + if err := sc.userService.UpdateAccount(user.ID, req.Username); err != nil { + utils.BadRequest(c, err.Error()) + return + } + updated = true + logoutRequired = true + } + + // 2. 处理密码修改 + if req.NewPassword != "" { + if len(req.NewPassword) < 6 { + utils.BadRequest(c, "新密码至少6位") + return + } + if err := sc.userService.UpdatePassword(user.ID, req.NewPassword); err != nil { + utils.ServerError(c, "修改密码失败") + return + } + updated = true + logoutRequired = true + } + + if !updated { + utils.SuccessMsg(c, "未检测到变更内容") + return + } + + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventPasswordChanged, + Payload: map[string]interface{}{ + "username": user.Username, + }, + }) + + msg := "保存成功" + if logoutRequired { + msg += ",请重新登录" + } + utils.SuccessMsg(c, msg) +} + +// CleanLogs 清理日志 - 已移除,改为任务级别的日志清理配置 + +// GetSiteSettings 获取站点设置 +func (sc *SettingsController) GetSiteSettings(c *gin.Context) { + settings := sc.settingsService.GetSection(constant.SectionSite) + + // 纠正数据库中的空值,防止因配置冲突被意外置空 + if settings[constant.KeyTitle] == "" { + settings[constant.KeyTitle] = "任务池" + sc.settingsService.Set(constant.SectionSite, constant.KeyTitle, "任务池") + } + if settings[constant.KeySubtitle] == "" { + settings[constant.KeySubtitle] = "极致轻量、高性能的自动化任务调度平台" + sc.settingsService.Set(constant.SectionSite, constant.KeySubtitle, "极致轻量、高性能的自动化任务调度平台") + } + if settings[constant.KeyIcon] == "" { + settings[constant.KeyIcon] = constant.DefaultIcon + sc.settingsService.Set(constant.SectionSite, constant.KeyIcon, constant.DefaultIcon) + } + + // 解析 JSON 格式的 OpenAPI Token + if tokenJson, ok := settings[constant.KeyOpenapiToken]; ok && tokenJson != "" { + var tokenConfig vo.TokenConfig + if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err == nil { + settings["openapi_token"] = tokenConfig.Token + settings["openapi_token_expire"] = tokenConfig.ExpireAt + if tokenConfig.Enabled { + settings["openapi_enabled"] = "true" + } else { + settings["openapi_enabled"] = "false" + } + } + } + + // 获取日志清理配置 + settings["system_notice_days"] = sc.settingsService.Get(constant.SectionSystem, constant.KeySystemNoticeDays) + settings["system_notice_max_count"] = sc.settingsService.Get(constant.SectionSystem, constant.KeySystemNoticeMaxCount) + settings["push_log_days"] = sc.settingsService.Get(constant.SectionSystem, constant.KeyPushLogDays) + settings["push_log_max_count"] = sc.settingsService.Get(constant.SectionSystem, constant.KeyPushLogMaxCount) + settings["login_log_days"] = sc.settingsService.Get(constant.SectionSystem, constant.KeyLoginLogDays) + settings["login_log_max_count"] = sc.settingsService.Get(constant.SectionSystem, constant.KeyLoginLogMaxCount) + settings["scheduler_log_days"] = sc.settingsService.Get(constant.SectionSystem, constant.KeySchedulerLogDays) + settings["scheduler_log_max_count"] = sc.settingsService.Get(constant.SectionSystem, constant.KeySchedulerLogMaxCount) + + utils.Success(c, settings) +} + +// GetPublicSiteSettings 获取公开的站点设置(无需认证) +func (sc *SettingsController) GetPublicSiteSettings(c *gin.Context) { + settings := sc.settingsService.GetSection(constant.SectionSite) + + title := settings[constant.KeyTitle] + if title == "" { + title = "任务池" + } + subtitle := settings[constant.KeySubtitle] + if subtitle == "" { + subtitle = "极致轻量、高性能的自动化任务调度平台" + } + icon := settings[constant.KeyIcon] + if icon == "" { + icon = constant.DefaultIcon + } + + // 只返回公开信息 + utils.Success(c, gin.H{ + constant.KeyTitle: title, + constant.KeySubtitle: subtitle, + constant.KeyIcon: icon, + "demo_mode": constant.DemoMode, + }) +} + +// UpdateSiteSettings 更新站点设置 +func (sc *SettingsController) UpdateSiteSettings(c *gin.Context) { + var req struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Icon string `json:"icon"` + PageSize string `json:"page_size"` + CookieDays string `json:"cookie_days"` + OpenapiEnabled bool `json:"openapi_enabled"` + OpenapiToken string `json:"openapi_token"` + OpenapiTokenExpire string `json:"openapi_token_expire"` + SystemNoticeDays string `json:"system_notice_days"` + SystemNoticeMaxCount string `json:"system_notice_max_count"` + PushLogDays string `json:"push_log_days"` + PushLogMaxCount string `json:"push_log_max_count"` + LoginLogDays string `json:"login_log_days"` + LoginLogMaxCount string `json:"login_log_max_count"` + SchedulerLogDays string `json:"scheduler_log_days"` + SchedulerLogMaxCount string `json:"scheduler_log_max_count"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + openapiTokenJson := "" + if req.OpenapiToken != "" || req.OpenapiTokenExpire != "" || req.OpenapiEnabled { + tokenConfig := vo.TokenConfig{ + Enabled: req.OpenapiEnabled, + Token: req.OpenapiToken, + ExpireAt: req.OpenapiTokenExpire, + } + if b, err := json.Marshal(tokenConfig); err == nil { + openapiTokenJson = string(b) + } + } + + values := map[string]string{ + constant.KeyTitle: req.Title, + constant.KeySubtitle: req.Subtitle, + constant.KeyIcon: req.Icon, + constant.KeyPageSize: req.PageSize, + constant.KeyCookieDays: req.CookieDays, + constant.KeyOpenapiToken: openapiTokenJson, + } + + if err := sc.settingsService.SetSection(constant.SectionSite, values); err != nil { + utils.ServerError(c, "保存失败") + return + } + + // 保存日志清理配置 + sc.settingsService.Set(constant.SectionSystem, constant.KeySystemNoticeDays, req.SystemNoticeDays) + sc.settingsService.Set(constant.SectionSystem, constant.KeySystemNoticeMaxCount, req.SystemNoticeMaxCount) + sc.settingsService.Set(constant.SectionSystem, constant.KeyPushLogDays, req.PushLogDays) + sc.settingsService.Set(constant.SectionSystem, constant.KeyPushLogMaxCount, req.PushLogMaxCount) + sc.settingsService.Set(constant.SectionSystem, constant.KeyLoginLogDays, req.LoginLogDays) + sc.settingsService.Set(constant.SectionSystem, constant.KeyLoginLogMaxCount, req.LoginLogMaxCount) + sc.settingsService.Set(constant.SectionSystem, constant.KeySchedulerLogDays, req.SchedulerLogDays) + sc.settingsService.Set(constant.SectionSystem, constant.KeySchedulerLogMaxCount, req.SchedulerLogMaxCount) + + utils.SuccessMsg(c, "保存成功") +} + +// GenerateOpenapiToken 随机生成OpenAPI Token +func (sc *SettingsController) GenerateOpenapiToken(c *gin.Context) { + utils.Success(c, gin.H{ + "token": strings.ToLower(utils.RandomString(32)), + }) +} + +// GetSchedulerSettings 获取调度设置 +func (sc *SettingsController) GetSchedulerSettings(c *gin.Context) { + settings := sc.settingsService.GetSection(constant.SectionScheduler) + utils.Success(c, settings) +} + +// UpdateSchedulerSettings 更新调度设置 +func (sc *SettingsController) UpdateSchedulerSettings(c *gin.Context) { + var req struct { + WorkerCount string `json:"worker_count"` + QueueSize string `json:"queue_size"` + RateInterval string `json:"rate_interval"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + var workerCount int + if _, err := fmt.Sscanf(req.WorkerCount, "%d", &workerCount); err != nil || workerCount < 1 || workerCount > 1000 { + utils.BadRequest(c, "工作线程数必须在 1 至 1000 之间") + return + } + + var queueSize int + if _, err := fmt.Sscanf(req.QueueSize, "%d", &queueSize); err != nil || queueSize < 1 || queueSize > 50000 { + utils.BadRequest(c, "等待队列容量必须在 1 至 50000 之间") + return + } + + var rateInterval int + if _, err := fmt.Sscanf(req.RateInterval, "%d", &rateInterval); err != nil || rateInterval < 1 { + utils.BadRequest(c, "限频间隔必须为正整数") + return + } + + values := map[string]string{ + constant.KeyWorkerCount: req.WorkerCount, + constant.KeyQueueSize: req.QueueSize, + constant.KeyRateInterval: req.RateInterval, + } + + if err := sc.settingsService.SetSection(constant.SectionScheduler, values); err != nil { + utils.ServerError(c, "保存失败") + return + } + + // 重新加载 executor service + if sc.executorService != nil { + sc.executorService.Reload() + } + + utils.SuccessMsg(c, "保存成功") +} + +// GetPaths 获取系统路径信息 +func (sc *SettingsController) GetPaths(c *gin.Context) { + absScriptsDir, _ := filepath.Abs(constant.ScriptsWorkDir) + utils.Success(c, gin.H{ + "scripts_dir": absScriptsDir, + }) +} + +// GetAbout 获取关于信息 +func (sc *SettingsController) GetAbout(c *gin.Context) { + var taskCount, logCount, envCount int64 + database.DB.Model(&models.Task{}).Count(&taskCount) + database.DB.Model(&models.TaskLog{}).Count(&logCount) + database.DB.Model(&models.EnvironmentVariable{}).Count(&envCount) + + // 内存使用 + memUsage := "N/A" + if p, err := process.NewProcess(int32(os.Getpid())); err == nil { + if memInfo, err := p.MemoryInfo(); err == nil { + memUsage = formatBytes(memInfo.RSS) + } + } + + // 运行时间 + uptime := formatDuration(time.Since(constant.StartTime)) + + // 获取远程最新版本 + remoteVersion := "" + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequest("GET", "https://api.github.com/repos/engigu/taskpool/releases/latest", nil) + if err == nil { + req.Header.Set("User-Agent", "taskpool") + if resp, err := client.Do(req); err == nil { + defer resp.Body.Close() + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err == nil { + remoteVersion = release.TagName + } + } + } + + utils.Success(c, gin.H{ + "version": constant.Version, + "remote_version": remoteVersion, + "build_time": constant.BuildTime, + "mem_usage": memUsage, + "goroutines": runtime.NumGoroutine(), + "uptime": uptime, + "task_count": taskCount, + "log_count": logCount, + "env_count": envCount, + }) +} + +// GetChangelog 获取更新日志 +func (sc *SettingsController) GetChangelog(c *gin.Context) { + content, err := os.ReadFile("docs/guide/changelog.md") + if err != nil { + utils.Success(c, "暂无更新日志") + return + } + utils.Success(c, string(content)) +} + +// formatBytes 格式化字节数 +func formatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := uint64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// formatDuration 格式化时间间隔 +func formatDuration(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if days > 0 { + return fmt.Sprintf("%d天%d小时%d分钟%d秒", days, hours, minutes, seconds) + } + if hours > 0 { + return fmt.Sprintf("%d小时%d分钟%d秒", hours, minutes, seconds) + } + if minutes > 0 { + return fmt.Sprintf("%d分钟%d秒", minutes, seconds) + } + return fmt.Sprintf("%d秒", seconds) +} + +// GetLoginLogs 获取登录日志 +func (sc *SettingsController) GetLoginLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + username := c.Query("username") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + logs, total, err := sc.loginLogService.List(page, pageSize, username) + if err != nil { + utils.ServerError(c, "获取登录日志失败") + return + } + + // 将 AppLog 转换为 LoginLogVO 返回,保持前端兼容性 + vos := make([]*vo.LoginLogVO, len(logs)) + for i, log := range logs { + vos[i] = &vo.LoginLogVO{ + ID: log.ID, + Username: log.Title, + IP: log.RefID, + UserAgent: string(log.Content), + Status: log.Status, + Message: string(log.ErrorMsg), + CreatedAt: log.CreatedAt, + } + } + + utils.Success(c, utils.PaginationData{ + Data: vos, + Total: total, + Page: page, + PageSize: pageSize, + }) +} + +// CreateBackup 创建备份 +func (sc *SettingsController) CreateBackup(c *gin.Context) { + _, err := sc.backupService.CreateBackup() + if err != nil { + utils.ServerError(c, "创建备份失败: "+err.Error()) + return + } + utils.SuccessMsg(c, "备份创建成功") +} + +// GetBackupStatus 获取备份状态 +func (sc *SettingsController) GetBackupStatus(c *gin.Context) { + filePath := sc.backupService.GetBackupFile() + var backupTime string + if filePath != "" { + if info, err := os.Stat(filePath); err == nil { + backupTime = info.ModTime().Format("2006-01-02 15:04:05") + } + } + utils.Success(c, gin.H{ + "has_backup": filePath != "", + "backup_time": backupTime, + }) +} + +// DownloadBackup 下载备份文件 +func (sc *SettingsController) DownloadBackup(c *gin.Context) { + filePath := sc.backupService.GetBackupFile() + if filePath == "" { + utils.NotFound(c, "没有可下载的备份") + return + } + + // 检查文件是否存在 + if _, err := os.Stat(filePath); os.IsNotExist(err) { + sc.backupService.ClearBackup() + utils.NotFound(c, "备份文件不存在") + return + } + + // 设置响应头 + c.Header("Content-Disposition", "attachment; filename="+filepath.Base(filePath)) + c.Header("Content-Type", "application/zip") + c.File(filePath) + + // 下载后清除备份记录和文件 + go func() { + time.Sleep(time.Minute * 5) // 等待下载完成 + sc.backupService.ClearBackup() + }() +} + +// RestoreBackup 恢复备份 +func (sc *SettingsController) RestoreBackup(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + utils.BadRequest(c, "请上传备份文件") + return + } + + // 保存上传的文件 + tempPath := filepath.Join(os.TempDir(), file.Filename) + if err := c.SaveUploadedFile(file, tempPath); err != nil { + utils.ServerError(c, "保存文件失败") + return + } + defer os.Remove(tempPath) + + // 恢复备份 + if err := sc.backupService.Restore(tempPath); err != nil { + utils.ServerError(c, "恢复失败: "+err.Error()) + return + } + + utils.SuccessMsg(c, "恢复成功") +} + +// GetSectionSettings 获取指定 section 的所有设置 +func (sc *SettingsController) GetSectionSettings(c *gin.Context) { + section := c.Param("section") + if section == "" { + utils.BadRequest(c, "参数错误") + return + } + settings := sc.settingsService.GetSection(section) + utils.Success(c, settings) +} + +// UpdateSectionSettings 批量更新指定 section 的设置 +func (sc *SettingsController) UpdateSectionSettings(c *gin.Context) { + section := c.Param("section") + if section == "" { + utils.BadRequest(c, "参数错误") + return + } + + var values map[string]string + if err := c.ShouldBindJSON(&values); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := sc.settingsService.SetSection(section, values); err != nil { + utils.ServerError(c, "更新失败") + return + } + + // 当互联配置发生改变时,通知 tunnel 模块立刻应用新角色,启动或停止相关的后台协程 + if section == constant.SectionInterconnect { + if role, ok := values[constant.KeyInterconnectRole]; ok { + tunnel.ApplyRole(role) + } + } + + utils.SuccessMsg(c, "保存成功") +} + +// GetSetting 获取单个设置值 +func (sc *SettingsController) GetSetting(c *gin.Context) { + section := c.Param("section") + key := c.Param("key") + + if section == "" || key == "" { + utils.BadRequest(c, "参数错误") + return + } + + value := sc.settingsService.Get(section, key) + utils.Success(c, value) +} + +// GenerateSettingToken 为指定设置生成随机token +func (sc *SettingsController) GenerateSettingToken(c *gin.Context) { + section := c.Param("section") + key := c.Param("key") + + if section == "" || key == "" { + utils.BadRequest(c, "参数错误") + return + } + + // 生成32位随机token + token := strings.ToLower(utils.RandomString(32)) + + // 保存到数据库 + if err := sc.settingsService.Set(section, key, token); err != nil { + utils.ServerError(c, "保存失败") + return + } + + utils.Success(c, token) +} diff --git a/internal/controllers/system_ws_controller.go b/internal/controllers/system_ws_controller.go new file mode 100644 index 0000000..d001f32 --- /dev/null +++ b/internal/controllers/system_ws_controller.go @@ -0,0 +1,90 @@ +package controllers + +import ( + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/services" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type SystemWSController struct { + manager *services.SystemWSManager +} + +func NewSystemWSController() *SystemWSController { + return &SystemWSController{ + manager: services.GetSystemWSManager(), + } +} + +func (sc *SystemWSController) HandleEvents(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Errorf("[SystemWS] 升级 WebSocket 失败: %v", err) + return + } + + client := sc.manager.Register(conn) + defer sc.manager.Unregister(client) + + // 启动写循环 + go sc.writeLoop(client) + + // 启动读循环 (主要用于检测连接断开和维持心跳) + sc.readLoop(client) +} + +func (sc *SystemWSController) readLoop(client *services.ClientConnection) { + defer client.Close() + + client.Conn.SetReadLimit(constant.MaxMessageSize) + client.Conn.SetReadDeadline(time.Now().Add(constant.PongWait)) + client.Conn.SetPongHandler(func(string) error { + client.Conn.SetReadDeadline(time.Now().Add(constant.PongWait)) + return nil + }) + + for { + _, _, err := client.Conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + logger.Warnf("[SystemWS] 客户端异常断开: %v", err) + } + break + } + // 暂时不处理来自前端的消息,前端仅作为接收方 + } +} + +func (sc *SystemWSController) writeLoop(client *services.ClientConnection) { + ticker := time.NewTicker(constant.PingPeriod) + defer func() { + ticker.Stop() + client.Close() + }() + + for { + select { + case message, ok := <-client.Send: + client.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + // 通道关闭 + client.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + if err := client.Conn.WriteMessage(websocket.TextMessage, message); err != nil { + return + } + case <-ticker.C: + client.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := client.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/internal/controllers/task_controller.go b/internal/controllers/task_controller.go new file mode 100644 index 0000000..f1dc5b1 --- /dev/null +++ b/internal/controllers/task_controller.go @@ -0,0 +1,872 @@ +package controllers + +import ( + "encoding/json" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" + "os" +) + +type TaskController struct { + taskService *tasks.TaskService + executorService *tasks.ExecutorService + agentWSManager *services.AgentWSManager +} + +func NewTaskController(taskService *tasks.TaskService, executorService *tasks.ExecutorService) *TaskController { + return &TaskController{ + taskService: taskService, + executorService: executorService, + agentWSManager: services.GetAgentWSManager(), + } +} + +// resolveWorkDir 将相对路径转换为绝对路径 +func resolveWorkDir(workDir string) string { + if workDir == "" { + // 空则使用默认 scripts 目录 + absPath, err := filepath.Abs(constant.ScriptsWorkDir) + if err != nil { + return constant.ScriptsWorkDir + } + return absPath + } + // 如果已经是绝对路径,直接返回 + if strings.HasPrefix(workDir, constant.ScriptsDirPlaceholder) { + return workDir + } + if filepath.IsAbs(workDir) { + return workDir + } + // 相对路径,基于 scripts 目录 + fullPath := filepath.Join(constant.ScriptsWorkDir, workDir) + absPath, err := filepath.Abs(fullPath) + if err != nil { + return fullPath + } + return absPath +} + +// isValidDirName 校验目录名是否合法 +func isValidDirName(dirName string) bool { + if dirName == "." || strings.Contains(dirName, "/") || strings.Contains(dirName, "\\") || strings.Contains(dirName, "..") { + return false + } + for _, ch := range dirName { + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.') { + return false + } + } + return true +} + +// getRepoPhysicalPath 计算仓库任务的最终物理绝对路径 +func getRepoPhysicalPath(targetPath, dirName, sourceURL, branch string) string { + if dirName == "." { + return "" // 如果不追加目录,此逻辑不负责判断其根目录(共享的 scripts 目录) + } + finalDirName := dirName + if finalDirName == "" { + finalDirName = utils.GetRepoIdentifier(sourceURL, branch) + } + if finalDirName == "" { + return "" + } + + basePath := targetPath + if basePath == "" || basePath == constant.ScriptsDirPlaceholder { + basePath = constant.ScriptsWorkDir + } else if strings.HasPrefix(basePath, constant.ScriptsDirPlaceholder) { + basePath = filepath.Join(constant.ScriptsWorkDir, strings.TrimPrefix(basePath, constant.ScriptsDirPlaceholder)) + } else if !filepath.IsAbs(basePath) { + basePath = filepath.Join(constant.ScriptsWorkDir, basePath) + } + + fullPath := filepath.Join(basePath, finalDirName) + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "" + } + return absPath +} +// CreateTask 创建任务 +// @Summary 创建任务 +// @Description 创建一个新的任务 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body vo.TaskCreateReq true "任务创建信息" +// @Success 200 {object} utils.Response{data=vo.TaskVO} +// @Failure 400 {object} utils.Response +// @Router /tasks [post] +func (tc *TaskController) CreateTask(c *gin.Context) { + var req vo.TaskCreateReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 普通任务需要命令 + if req.Type != constant.TaskTypeRepo && req.Command == "" { + utils.BadRequest(c, "命令不能为空") + return + } + + if req.Schedule != "" { + if err := tc.executorService.ValidateCron(req.Schedule); err != nil { + utils.BadRequest(c, "无效的cron表达式: "+err.Error()) + return + } + } + + // 转换为绝对路径(Agent 任务保持原样) + workDir := req.WorkDir + if req.AgentID == nil || *req.AgentID == "" { + workDir = resolveWorkDir(req.WorkDir) + } + + var sourceID string + // 如果是仓库同步任务,根据 URL 生成 SourceID 用于去重 + if req.Type == constant.TaskTypeRepo && req.Config != "" { + var repoCfg struct { + SourceURL string `json:"source_url"` + Branch string `json:"branch"` + RepoDirName string `json:"repo_dir_name"` + TargetPath string `json:"target_path"` + } + if err := json.Unmarshal([]byte(req.Config), &repoCfg); err == nil && repoCfg.SourceURL != "" { + if repoCfg.RepoDirName != "" { + if !isValidDirName(repoCfg.RepoDirName) { + utils.BadRequest(c, "自定义目录名只能包含字母、数字、下划线、短划线和点,不能只有点,且不能包含路径逻辑") + return + } + } + + // 如果配置了自定义名字,使用配置的名字。没有配置的话,使用以前的username_reponame + if repoCfg.RepoDirName != "" { + sourceID = "repo_" + repoCfg.RepoDirName + } else { + sourceID = "repo_" + utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch) + } + + // 校验 SourceID 是否已存在(任务唯一性) + existingTask := tc.taskService.GetTaskBySourceID(sourceID) + if existingTask != nil { + utils.BadRequest(c, "当前任务已存在,请检查或更换仓库目录名称") + return + } + + // 校验物理目录是否存在 + newAbsPath := getRepoPhysicalPath(repoCfg.TargetPath, repoCfg.RepoDirName, repoCfg.SourceURL, repoCfg.Branch) + if newAbsPath != "" { + if info, err := os.Stat(newAbsPath); err == nil && info.IsDir() { + utils.BadRequest(c, "本地已存在同名仓库文件夹,请更换自定义目录名或清理残留文件") + return + } + } + } + } + + param := tasks.TaskParam{ + Name: req.Name, + Remark: req.Remark, + Command: req.Command, + PreCommand: req.PreCommand, + PostCommand: req.PostCommand, + Tags: req.Tags, + Type: req.Type, + Config: req.Config, + Schedule: req.Schedule, + Timeout: req.Timeout, + WorkDir: workDir, + CleanConfig: req.CleanConfig, + Envs: req.Envs, + Languages: req.Languages, + AgentID: req.AgentID, + TriggerType: req.TriggerType, + RetryCount: req.RetryCount, + RetryInterval: req.RetryInterval, + RandomRange: req.RandomRange, + SourceID: sourceID, + PinType: req.PinType, + Enabled: true, + } + + var task *models.Task + // 去重逻辑:如果已存在相同 SourceID 的仓库任务,则改为更新 + if sourceID != "" { + task = tc.taskService.GetTaskBySourceID(sourceID) + if task != nil { + task = tc.taskService.UpdateTask(task.ID, ¶m) + } + } + + if task == nil { + task = tc.taskService.CreateTask(¶m) + } + + // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron + if task.AgentID != nil && *task.AgentID != "" { + tc.agentWSManager.BroadcastTasks(*task.AgentID) + } else { + tc.executorService.AddCronTask(task) + } + + utils.Success(c, vo.ToTaskVO(task)) +} + +// BulkSaveTask 批量保存/导入任务配置(用于主节点下发同步) +// @Summary 批量保存任务 +// @Description 批量导入任务配置,如果ID或同名存在则更新,不存在则创建 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Router /tasks/bulk_save [post] +func (tc *TaskController) BulkSaveTask(c *gin.Context) { + var reqs []vo.TaskVO + + if err := c.ShouldBindJSON(&reqs); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + for _, req := range reqs { + param := tasks.TaskParam{ + Name: req.Name, + Remark: req.Remark, + Command: req.Command, + PreCommand: req.PreCommand, + PostCommand: req.PostCommand, + Tags: req.Tags, + Type: req.Type, + Config: req.Config, + Schedule: req.Schedule, + Timeout: req.Timeout, + WorkDir: req.WorkDir, + CleanConfig: req.CleanConfig, + Envs: req.Envs, + Languages: req.Languages, + AgentID: req.AgentID, + TriggerType: req.TriggerType, + RetryCount: req.RetryCount, + RetryInterval: req.RetryInterval, + RandomRange: req.RandomRange, + PinType: req.PinType, + Enabled: req.Enabled, + SourceID: "", // 不直接覆盖 + } + + var existingTask *models.Task + // 优先按 ID 匹配 + if req.ID != "" { + existingTask = tc.taskService.GetTaskByID(req.ID) + } + // 如果 ID 没找到,尝试按 Name 匹配 + if existingTask == nil { + var t models.Task + res := database.DB.Where("name = ?", req.Name).First(&t) + if res.Error == nil { + existingTask = &t + } + } + + var savedTask *models.Task + if existingTask != nil { + savedTask = tc.taskService.UpdateTask(existingTask.ID, ¶m) + } else { + savedTask = tc.taskService.CreateTask(¶m) + // 如果原始有 ID,强制覆盖更新 ID 保持强同步一致性 + if req.ID != "" && savedTask != nil { + database.DB.Model(savedTask).Update("id", req.ID) + savedTask.ID = req.ID + } + } + + // 如果是 Agent 任务,通知 Agent;否则添加到本地 cron + if savedTask != nil { + if savedTask.AgentID != nil && *savedTask.AgentID != "" { + tc.agentWSManager.BroadcastTasks(*savedTask.AgentID) + } else { + tc.executorService.AddCronTask(savedTask) + } + } + } + + utils.Success(c, nil) +} + +// GetTasks 获取任务列表 +// @Summary 获取任务列表 +// @Description 分页获取任务列表,支持按名称、Agent ID、标签、类型筛选 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param name query string false "任务名称" +// @Param agent_id query string false "Agent ID" +// @Param tags query string false "标签" +// @Param type query string false "任务类型" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} utils.Response{data=utils.PaginationData{data=[]vo.TaskVO}} +// @Router /tasks [get] +func (tc *TaskController) GetTasks(c *gin.Context) { + p := utils.ParsePagination(c) + name := c.DefaultQuery("name", "") + agentIDStr := c.DefaultQuery("agent_id", "") + + tags := c.DefaultQuery("tags", "") + taskType := c.DefaultQuery("type", "") + + var agentID *string + if agentIDStr != "" { + agentID = &agentIDStr + } + + sortBy := c.DefaultQuery("sort_by", "") + order := c.DefaultQuery("order", "") + + tasks, total := tc.taskService.GetTasksWithPagination(p.Page, p.PageSize, name, agentID, tags, taskType, sortBy, order) + utils.PaginatedResponse(c, vo.ToTaskVOListFromModels(tasks), total, p) +} + +// GetTask 获取任务详情 +// @Summary 获取任务详情 +// @Description 根据 ID 获取任务详情 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "任务ID" +// @Success 200 {object} utils.Response{data=vo.TaskVO} +// @Failure 404 {object} utils.Response +// @Router /tasks/{id} [get] +func (tc *TaskController) GetTask(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的任务ID") + return + } + + task := tc.taskService.GetTaskByID(id) + if task == nil { + utils.NotFound(c, "任务不存在") + return + } + + utils.Success(c, vo.ToTaskVO(task)) +} + +// UpdateTask 更新任务 +// @Summary 更新任务 +// @Description 根据 ID 更新任务信息 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "任务ID" +// @Param body body vo.TaskUpdateReq true "任务更新信息" +// @Success 200 {object} utils.Response{data=vo.TaskVO} +// @Failure 404 {object} utils.Response +// @Router /tasks/{id} [put] +func (tc *TaskController) UpdateTask(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的任务ID") + return + } + + // 获取旧任务信息(用于判断 agent 变更) + oldTask := tc.taskService.GetTaskByID(id) + var oldAgentID *string + if oldTask != nil { + oldAgentID = oldTask.AgentID + } + + var req vo.TaskUpdateReq + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + if req.Schedule != "" { + if err := tc.executorService.ValidateCron(req.Schedule); err != nil { + utils.BadRequest(c, "无效的cron表达式: "+err.Error()) + return + } + } + + // 转换为绝对路径(Agent 任务保持原样) + workDir := req.WorkDir + if req.AgentID == nil || *req.AgentID == "" { + workDir = resolveWorkDir(req.WorkDir) + } + + var sourceID string + if req.Type == constant.TaskTypeRepo && req.Config != "" { + var repoCfg struct { + SourceURL string `json:"source_url"` + Branch string `json:"branch"` + RepoDirName string `json:"repo_dir_name"` + TargetPath string `json:"target_path"` + } + if err := json.Unmarshal([]byte(req.Config), &repoCfg); err == nil && repoCfg.SourceURL != "" { + if repoCfg.RepoDirName != "" { + if !isValidDirName(repoCfg.RepoDirName) { + utils.BadRequest(c, "自定义目录名只能包含字母、数字、下划线、短划线和点,不能只有点,且不能包含路径逻辑") + return + } + } + + // 如果配置了自定义名字,使用配置的名字。没有配置的话,使用以前的username_reponame + if repoCfg.RepoDirName != "" { + sourceID = "repo_" + repoCfg.RepoDirName + } else { + sourceID = "repo_" + utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch) + } + + // 验证更新后的 SourceID 是否和别的任务冲突 + if sourceID != oldTask.SourceID { + existingTask := tc.taskService.GetTaskBySourceID(sourceID) + if existingTask != nil && existingTask.ID != oldTask.ID { + utils.BadRequest(c, "当前任务已存在,请检查或更换仓库目录名称") + return + } + } + + // 计算新的物理路径 + newAbsPath := getRepoPhysicalPath(repoCfg.TargetPath, repoCfg.RepoDirName, repoCfg.SourceURL, repoCfg.Branch) + + var oldAbsPath string + if oldTask != nil && oldTask.Type == constant.TaskTypeRepo && oldTask.Config != "" { + var oldCfg struct { + SourceURL string `json:"source_url"` + Branch string `json:"branch"` + RepoDirName string `json:"repo_dir_name"` + TargetPath string `json:"target_path"` + } + if json.Unmarshal([]byte(oldTask.Config), &oldCfg) == nil { + oldAbsPath = getRepoPhysicalPath(oldCfg.TargetPath, oldCfg.RepoDirName, oldCfg.SourceURL, oldCfg.Branch) + } + } + + // 如果路径发生了改变(或者是个全新计算的路径),并且新路径已存在,则报错拦截 + if newAbsPath != "" && newAbsPath != oldAbsPath { + if info, err := os.Stat(newAbsPath); err == nil && info.IsDir() { + utils.BadRequest(c, "目标目录在本地已存在同名文件夹,请更换目录名或清理残留文件") + return + } + } + } + } else if oldTask != nil { + sourceID = oldTask.SourceID + } + + param := tasks.TaskParam{ + Name: req.Name, + Remark: req.Remark, + Command: req.Command, + PreCommand: req.PreCommand, + PostCommand: req.PostCommand, + Tags: req.Tags, + Type: req.Type, + Config: req.Config, + Schedule: req.Schedule, + Timeout: req.Timeout, + WorkDir: workDir, + CleanConfig: req.CleanConfig, + Envs: req.Envs, + Languages: req.Languages, + AgentID: req.AgentID, + TriggerType: req.TriggerType, + RetryCount: req.RetryCount, + RetryInterval: req.RetryInterval, + RandomRange: req.RandomRange, + SourceID: sourceID, + PinType: req.PinType, + Enabled: req.Enabled, + } + + task := tc.taskService.UpdateTask(id, ¶m) + if task == nil { + utils.NotFound(c, "任务不存在") + return + } + + // 处理任务调度 + if task.AgentID != nil && *task.AgentID != "" { + // Agent 任务:从本地 cron 移除,通知 Agent + tc.executorService.RemoveCronTask(task.ID) + tc.agentWSManager.BroadcastTasks(*task.AgentID) + // 如果 agent 变更了,也通知旧 agent + if oldAgentID != nil && *oldAgentID != "" && *oldAgentID != *task.AgentID { + tc.agentWSManager.BroadcastTasks(*oldAgentID) + } + } else { + // 本地任务 + if utils.DerefBool(task.Enabled, true) { + tc.executorService.AddCronTask(task) + } else { + tc.executorService.RemoveCronTask(task.ID) + } + // 如果之前是 agent 任务,通知旧 agent 移除 + if oldAgentID != nil && *oldAgentID != "" { + tc.agentWSManager.BroadcastTasks(*oldAgentID) + } + } + + utils.Success(c, vo.ToTaskVO(task)) +} + +// DeleteTask 删除任务 +// @Summary 删除任务 +// @Description 根据 ID 删除任务 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "任务ID" +// @Success 200 {object} utils.Response +// @Failure 404 {object} utils.Response +// @Router /tasks/{id} [delete] +func (tc *TaskController) DeleteTask(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的任务ID") + return + } + + // 获取任务信息(用于通知 agent 和物理删除校验) + task := tc.taskService.GetTaskByID(id) + if task == nil { + utils.NotFound(c, "任务不存在") + return + } + + agentID := task.AgentID + deleteFiles := c.Query("delete_files") == "true" + + // 如果需要删除物理文件且是仓库任务 + if deleteFiles && task.Type == constant.TaskTypeRepo { + tc.deleteRepoPhysicalFiles(task) + } + + tc.executorService.RemoveCronTask(id) + tc.executorService.GetScheduler().StopTask(id) + + success := tc.taskService.DeleteTask(id) + if !success { + utils.NotFound(c, "任务不存在") + return + } + + // 如果是 agent 任务,通知 agent + if agentID != nil && *agentID != "" { + tc.agentWSManager.BroadcastTasks(*agentID) + } + + utils.SuccessMsg(c, "删除成功") +} + +// deleteRepoPhysicalFiles 删除仓库关联的物理文件 +func (tc *TaskController) deleteRepoPhysicalFiles(task *models.Task) { + if task.Type != constant.TaskTypeRepo { + return + } + + logger.Infof("[Controller] 开始尝试物理删除任务关联文件: %s", task.Name) + var repoCfg models.RepoConfig + if err := json.Unmarshal([]byte(task.Config), &repoCfg); err != nil { + logger.Errorf("[Controller] 解析任务配置失败: %v", err) + return + } + + targetPath := repoCfg.TargetPath + if targetPath == "" { + // 如果 TargetPath 为空,调用系统的计算函数获取默认目录名 + repoId := utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch) + if repoId != "" { + targetPath = repoId + logger.Infof("[Controller] TargetPath 为空,使用计算出的标识符: %s", targetPath) + } + } + + if targetPath == "" || targetPath == constant.ScriptsDirPlaceholder { + logger.Warnf("[Controller] 任务 %s 无法确定有效的物理删除路径,跳过", task.Name) + return + } + + // 确定绝对路径 + scriptsDir, _ := filepath.Abs(constant.ScriptsWorkDir) + fullPath := targetPath + if strings.HasPrefix(targetPath, constant.ScriptsDirPlaceholder) { + fullPath = filepath.Join(scriptsDir, strings.TrimPrefix(targetPath, constant.ScriptsDirPlaceholder)) + } else if !filepath.IsAbs(targetPath) { + fullPath = filepath.Join(scriptsDir, targetPath) + } + + absTargetPath, _ := filepath.Abs(fullPath) + logger.Infof("[Controller] 最终计算的绝对路径: %s, Scripts目录: %s", absTargetPath, scriptsDir) + scriptsDir, _ = filepath.Abs(constant.ScriptsWorkDir) + + // 安全检查:使用 Rel 判断路径关系 + rel, err := filepath.Rel(scriptsDir, absTargetPath) + if err != nil { + logger.Errorf("[Controller] 计算相对路径失败: %v", err) + return + } + + // 必须是在 scripts 目录下(不以 .. 开头)且不能是 scripts 目录本身 (.) + if rel != "." && !strings.HasPrefix(rel, "..") { + err := os.RemoveAll(absTargetPath) + if err != nil { + logger.Errorf("[Controller] 物理删除文件夹失败: %s, 路径: %s, 错误: %v", task.Name, absTargetPath, err) + } else { + logger.Infof("[Controller] 已成功物理删除文件夹: %s, 路径: %s", task.Name, absTargetPath) + } + } else { + logger.Warnf("[Controller] 拒绝物理删除安全目录之外的路径: %s", absTargetPath) + } +} + +func (tc *TaskController) BatchDeleteTasks(c *gin.Context) { + var req struct { + IDs []string `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 收集涉及到的 AgentID + agentIDs := make(map[string]struct{}) + for _, id := range req.IDs { + // 获取任务信息 + task := tc.taskService.GetTaskByID(id) + if task != nil { + if task.AgentID != nil && *task.AgentID != "" { + agentIDs[*task.AgentID] = struct{}{} + } + } + + // 移除 cron 调度 + tc.executorService.RemoveCronTask(id) + tc.executorService.GetScheduler().StopTask(id) + } + + // 执行批量删除 + count := tc.taskService.BatchDeleteTasks(req.IDs) + + // 通知受影响的 Agent + for agentID := range agentIDs { + tc.agentWSManager.BroadcastTasks(agentID) + } + + utils.Success(c, gin.H{"count": count}) +} + +// BatchDeleteByQuery 根据查询条件批量删除任务 +// @Summary 根据查询条件批量删除任务 +// @Description 根据查询条件批量删除匹配的所有任务 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param name query string false "任务名称关键词" +// @Param tags query string false "标签关键词" +// @Param type query string false "任务类型" +// @Param agent_id query string false "执行位置(节点ID)" +// @Success 200 {object} utils.Response{data=map[string]int} +// @Failure 401 {object} utils.Response "未授权" +// @Router /tasks/batch-by-query [delete] +func (tc *TaskController) BatchDeleteByQuery(c *gin.Context) { + name := c.Query("name") + agentIDStr := c.Query("agent_id") + tags := c.Query("tags") + taskType := c.Query("type") + + var agentID *string + if agentIDStr != "" { + agentID = &agentIDStr + } + + tasks, _ := tc.taskService.GetTasksWithPagination(1, 999999, name, agentID, tags, taskType, "", "") + if len(tasks) == 0 { + utils.Success(c, gin.H{"count": 0}) + return + } + + var ids []string + agentIDs := make(map[string]struct{}) + for _, task := range tasks { + ids = append(ids, task.ID) + if task.AgentID != nil && *task.AgentID != "" { + agentIDs[*task.AgentID] = struct{}{} + } + // 移除 cron 调度 + tc.executorService.RemoveCronTask(task.ID) + tc.executorService.GetScheduler().StopTask(task.ID) + } + + // 执行批量删除 + count := tc.taskService.BatchDeleteTasks(ids) + + // 通知受影响的 Agent + for aID := range agentIDs { + tc.agentWSManager.BroadcastTasks(aID) + } + + utils.Success(c, gin.H{"count": count}) +} + +// StopTask 停止任务 +// @Summary 停止任务 +// @Description 根据运行日志 ID 停止正在执行的任务 +// @Tags 任务管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param logID path string true "运行日志ID" +// @Success 200 {object} utils.Response +// @Failure 400 {object} utils.Response +// @Router /tasks/stop/{logID} [post] +func (tc *TaskController) StopTask(c *gin.Context) { + logID := c.Param("logID") + if logID == "" { + utils.BadRequest(c, "无效的日志ID") + return + } + + err := tc.executorService.StopTaskExecution(logID) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + + utils.SuccessMsg(c, "停止请求已发送") +} + +// GetTags 获取所有任务标签 +// @Summary 获取所有任务标签 +// @Description 获取系统中所有任务已使用的唯一标签列表 +// @Tags 任务管理 +// @Produce json +// @Security BearerAuth +// @Success 200 {object} utils.Response{data=[]string} +// @Router /tasks/tags [get] +func (tc *TaskController) GetTags(c *gin.Context) { + tags, err := tc.taskService.GetAllTags() + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.Success(c, tags) +} + +// SyncRepoTasks 增量同步仓库任务状态(供本地 reposync 进程调用) +func (tc *TaskController) SyncRepoTasks(c *gin.Context) { + var req struct { + RepoID string `json:"repo_id"` + UpsertedIDs []string `json:"upserted_ids"` + DeletedIDs []string `json:"deleted_ids"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + tc.executorService.SyncRepoTasks(req.UpsertedIDs, req.DeletedIDs) + utils.SuccessMsg(c, "增量同步成功") +} + +// ToggleTask 切换任务启用/禁用状态 +func (tc *TaskController) ToggleTask(c *gin.Context) { + id := c.Param("id") + if id == "" { + utils.BadRequest(c, "无效的任务ID") + return + } + + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + task := tc.taskService.GetTaskByID(id) + if task == nil { + utils.NotFound(c, "任务不存在") + return + } + + // 获取旧 AgentID + var oldAgentID *string + oldAgentID = task.AgentID + + // 构造更新参数,仅修改 Enabled + param := tasks.TaskParam{ + Name: task.Name, + Remark: task.Remark, + Command: string(task.Command), + PreCommand: string(task.PreCommand), + PostCommand: string(task.PostCommand), + Tags: task.Tags, + Type: task.Type, + Config: string(task.Config), + Schedule: task.Schedule, + Timeout: task.Timeout, + WorkDir: task.WorkDir, + CleanConfig: task.CleanConfig, + Envs: string(task.Envs), + Languages: task.Languages, + AgentID: task.AgentID, + TriggerType: task.TriggerType, + RetryCount: task.RetryCount, + RetryInterval: task.RetryInterval, + RandomRange: task.RandomRange, + SourceID: task.SourceID, + PinType: task.PinType, + Enabled: req.Enabled, + } + + updatedTask := tc.taskService.UpdateTask(id, ¶m) + if updatedTask == nil { + utils.NotFound(c, "任务不存在") + return + } + + // 处理调度器更新 + if updatedTask.AgentID != nil && *updatedTask.AgentID != "" { + tc.executorService.RemoveCronTask(updatedTask.ID) + tc.agentWSManager.BroadcastTasks(*updatedTask.AgentID) + } else { + if req.Enabled { + tc.executorService.AddCronTask(updatedTask) + } else { + tc.executorService.RemoveCronTask(updatedTask.ID) + } + if oldAgentID != nil && *oldAgentID != "" { + tc.agentWSManager.BroadcastTasks(*oldAgentID) + } + } + + utils.Success(c, vo.ToTaskVO(updatedTask)) +} diff --git a/internal/controllers/terminal_controller.go b/internal/controllers/terminal_controller.go new file mode 100644 index 0000000..f984b8e --- /dev/null +++ b/internal/controllers/terminal_controller.go @@ -0,0 +1,476 @@ +package controllers + +import ( + "bufio" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + + "github.com/creack/pty" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +type TerminalController struct { + envService *services.EnvService +} + +func NewTerminalController(envService *services.EnvService) *TerminalController { + return &TerminalController{ + envService: envService, + } +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: utils.CheckWSOrigin, +} + +// toUTF8 将可能是 GBK 编码的字节转换为 UTF-8 +func toUTF8(data []byte) string { + if utf8.Valid(data) { + return string(data) + } + // 尝试从 GBK 转换 + reader := transform.NewReader( + bufio.NewReader( + &byteReader{data: data}, + ), + simplifiedchinese.GBK.NewDecoder(), + ) + result, err := io.ReadAll(reader) + if err != nil { + return string(data) + } + return string(result) +} + +type byteReader struct { + data []byte + pos int +} + +func (r *byteReader) Read(p []byte) (n int, err error) { + if r.pos >= len(r.data) { + return 0, io.EOF + } + n = copy(p, r.data[r.pos:]) + r.pos += n + return n, nil +} + +func (tc *TerminalController) HandleWebSocket(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + // 演示模式下禁用终端 + if constant.DemoMode { + conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[1;33m[演示模式] 终端功能已禁用\033[0m\r\n")) + return + } + + // Windows 使用 pipe 模式,Unix 使用 PTY 模式 + userID := c.GetString("userID") + if userID == "" { + userID = "1" // 兜底 + } + if runtime.GOOS == "windows" { + tc.handlePipeMode(conn, userID) + } else { + tc.handlePtyMode(conn, userID) + } +} + +// handlePtyMode 使用 PTY 处理终端(Unix/macOS) +func (tc *TerminalController) handlePtyMode(conn *websocket.Conn, userID string) { + conn.SetReadLimit(constant.MaxMessageSize) + conn.SetReadDeadline(time.Now().Add(constant.PongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(constant.PongWait)) + return nil + }) + + // 发送 PTY 模式标识 + conn.WriteMessage(websocket.TextMessage, []byte("__PTY_MODE__")) + + cmd := utils.NewShellCmd() + + if absDir, err := filepath.Abs(constant.ScriptsWorkDir); err == nil { + cmd.Dir = absDir + } + + cmd.Env = tc.buildTerminalEnv(userID, "TERM=xterm-256color") + + ptmx, err := pty.Start(cmd) + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte("Error starting shell: "+err.Error())) + return + } + defer ptmx.Close() + + pty.Setsize(ptmx, &pty.Winsize{Rows: 24, Cols: 80}) + + var wg sync.WaitGroup + var connMu sync.Mutex + + writeMessage := func(data []byte) { + connMu.Lock() + defer connMu.Unlock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + conn.WriteMessage(websocket.TextMessage, data) + } + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 4096) + var remainder []byte + for { + n, err := ptmx.Read(buf) + if n > 0 { + chunk := append(remainder, buf[:n]...) + lastSafe := len(chunk) + for i := len(chunk); i > 0 && i > len(chunk)-4; i-- { + if utf8.RuneStart(chunk[i-1]) { + if !utf8.FullRune(chunk[i-1 : len(chunk)]) { + lastSafe = i - 1 + } + break + } + } + safe := chunk[:lastSafe] + + if len(safe) == 0 && len(chunk) >= 4 { + safe = chunk + remainder = nil + } else { + remainder = make([]byte, len(chunk[lastSafe:])) + copy(remainder, chunk[lastSafe:]) + } + + if len(safe) > 0 { + text := toUTF8(safe) + writeMessage([]byte(text)) + } + } + if err != nil { + if len(remainder) > 0 { + writeMessage([]byte(toUTF8(remainder))) + } + return + } + } + }() + + // 启动 ping 协程 + pingDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(constant.PingPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + connMu.Lock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + connMu.Unlock() + return + } + connMu.Unlock() + case <-pingDone: + return + } + } + }() + + for { + _, message, err := conn.ReadMessage() + if err != nil { + break + } + + // 处理调整窗口大小的消息 + if len(message) > 0 && message[0] == '{' { + var resizeMsg struct { + Type string `json:"type"` + Rows uint16 `json:"rows"` + Cols uint16 `json:"cols"` + } + if err := json.Unmarshal(message, &resizeMsg); err == nil && resizeMsg.Type == "resize" { + pty.Setsize(ptmx, &pty.Winsize{Rows: resizeMsg.Rows, Cols: resizeMsg.Cols}) + continue + } + } + + if _, err := ptmx.Write(message); err != nil { + break + } + } + + close(pingDone) + cmd.Process.Kill() + cmd.Wait() + ptmx.Close() // Force close PTY to interrupt the blocking ptmx.Read() in the goroutine + wg.Wait() +} + +// handlePipeMode 使用 pipe 处理终端(Windows) +func (tc *TerminalController) handlePipeMode(conn *websocket.Conn, userID string) { + conn.SetReadLimit(constant.MaxMessageSize) + conn.SetReadDeadline(time.Now().Add(constant.PongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(constant.PongWait)) + return nil + }) + + // 发送 pipe 模式标识 + conn.WriteMessage(websocket.TextMessage, []byte("__PIPE_MODE__")) + + cmd := utils.NewShellCmd() + + if absDir, err := filepath.Abs(constant.ScriptsWorkDir); err == nil { + cmd.Dir = absDir + } + + // 注入环境变量 + cmd.Env = tc.buildTerminalEnv(userID) + + stdin, err := cmd.StdinPipe() + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) + return + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) + return + } + + stderr, err := cmd.StderrPipe() + if err != nil { + conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) + return + } + + if err := cmd.Start(); err != nil { + conn.WriteMessage(websocket.TextMessage, []byte("Error: "+err.Error())) + return + } + + var wg sync.WaitGroup + var connMu sync.Mutex + + writeMessage := func(data []byte) { + connMu.Lock() + defer connMu.Unlock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + conn.WriteMessage(websocket.TextMessage, data) + } + + readOutput := func(reader io.Reader) { + defer wg.Done() + defer func() { recover() }() + buf := make([]byte, 4096) + var remainder []byte + for { + n, err := reader.Read(buf) + if n > 0 { + chunk := append(remainder, buf[:n]...) + lastSafe := len(chunk) + for i := len(chunk); i > 0 && i > len(chunk)-4; i-- { + if utf8.RuneStart(chunk[i-1]) { + if !utf8.FullRune(chunk[i-1 : len(chunk)]) { + lastSafe = i - 1 + } + break + } + } + safe := chunk[:lastSafe] + + if len(safe) == 0 && len(chunk) >= 4 { + safe = chunk + remainder = nil + } else { + remainder = make([]byte, len(chunk[lastSafe:])) + copy(remainder, chunk[lastSafe:]) + } + + if len(safe) > 0 { + text := toUTF8(safe) + writeMessage([]byte(text)) + } + } + if err != nil { + if len(remainder) > 0 { + writeMessage([]byte(toUTF8(remainder))) + } + return + } + } + } + + wg.Add(2) + go readOutput(stdout) + go readOutput(stderr) + + // 启动 ping 协程 + pingDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(constant.PingPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + connMu.Lock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + connMu.Unlock() + return + } + connMu.Unlock() + case <-pingDone: + return + } + } + }() + + for { + _, message, err := conn.ReadMessage() + if err != nil { + break + } + // 过滤调整窗口大小的消息(Windows Pipe 模式不支持调整尺寸,需过滤掉避免写入 stdin) + if len(message) > 0 && message[0] == '{' { + var resizeMsg struct { + Type string `json:"type"` + } + if err := json.Unmarshal(message, &resizeMsg); err == nil && resizeMsg.Type == "resize" { + continue + } + } + + if _, err := stdin.Write(message); err != nil { + break + } + } + + close(pingDone) + cmd.Process.Kill() + cmd.Wait() + + if stdinCloser, ok := stdin.(io.Closer); ok { + stdinCloser.Close() + } + if stdoutCloser, ok := stdout.(io.Closer); ok { + stdoutCloser.Close() + } + if stderrCloser, ok := stderr.(io.Closer); ok { + stderrCloser.Close() + } + + wg.Wait() +} + +// ExecuteShellCommand 执行单个命令并返回结果 +func (tc *TerminalController) ExecuteShellCommand(c *gin.Context) { + // 演示模式下禁止执行命令 + if constant.DemoMode { + utils.BadRequest(c, "演示模式下不能执行命令") + return + } + + var req struct { + Command string `json:"command" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + cmd := utils.NewShellCommandCmd(req.Command) + userID := c.GetString("userID") + if userID == "" { + userID = "1" // 与 WebSocket 终端保持一致,保留原有兜底行为 + } + cmd.Env = tc.buildTerminalEnv(userID) + output, err := cmd.CombinedOutput() + + if err != nil { + utils.Success(c, gin.H{ + "output": string(output), + "error": err.Error(), + }) + return + } + + utils.Success(c, gin.H{ + "output": string(output), + }) +} + +func (tc *TerminalController) buildTerminalEnv(userID string, extraEnvs ...string) []string { + env := os.Environ() + env = append(env, extraEnvs...) + + // 注入 taskpool 命令运行时路径与配置,保证在终端里手动执行 taskpool 子命令时 + // 仍然能连接到与主服务一致的数据库,而不是回退到默认 sqlite。 + if absBinDir, err := filepath.Abs(filepath.Join(constant.DataDir, "bin")); err == nil { + pathStr := absBinDir + string(os.PathListSeparator) + os.Getenv("PATH") + env = append(env, "PATH="+pathStr) + } + env = append(env, utils.BuildRuntimeProcessEnv()...) + + // 注入环境变量(支持同名合并) + if tc.envService != nil { + envVars := tc.envService.GetFormattedEnvVarsByUserID(userID) + env = append(env, envVars...) + } + + // 为 Docker 环境或二进制版本注入所有 mise 已安装 Node 的全局依赖路径到 NODE_PATH (Issue-90) + if !utils.IsInDocker() || (!strings.Contains(os.Args[0], "go-build") && !strings.Contains(os.Args[0], "tmp")) { + versions, _ := utils.ListMiseInstalledVersions("node") + var nodePaths []string + for _, v := range versions { + if p := utils.GetMiseNodePath(v); p != "" { + nodePaths = append(nodePaths, p) + } + } + if len(nodePaths) > 0 { + sep := ":" + if runtime.GOOS == "windows" { + sep = ";" + } + env = append(env, "NODE_PATH="+strings.Join(nodePaths, sep)) + } + } + + return env +} + +// GetCommands 获取所有可用的 cmd 列表及说明 +func (tc *TerminalController) GetCommands(c *gin.Context) { + var cmds []map[string]string + for _, cmdInfo := range constant.Commands { + cmds = append(cmds, map[string]string{ + "name": cmdInfo.Name, + "description": cmdInfo.Description, + }) + } + utils.Success(c, cmds) +} diff --git a/internal/controllers/webui_controller.go b/internal/controllers/webui_controller.go new file mode 100644 index 0000000..a270bfc --- /dev/null +++ b/internal/controllers/webui_controller.go @@ -0,0 +1,93 @@ +package controllers + +import ( + "os" + "path/filepath" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + "github.com/gin-gonic/gin" +) + +type WebUIController struct { + webuiService *services.WebUIService +} + +func NewWebUIController(webuiService *services.WebUIService) *WebUIController { + return &WebUIController{ + webuiService: webuiService, + } +} + +// GetWebUIs 获取所有WebUI +func (c *WebUIController) GetWebUIs(ctx *gin.Context) { + webuis, err := c.webuiService.GetWebUIs() + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.Success(ctx, webuis) +} + +// UploadWebUI 上传新WebUI +func (c *WebUIController) UploadWebUI(ctx *gin.Context) { + file, err := ctx.FormFile("file") + if err != nil { + utils.BadRequest(ctx, "获取上传文件失败") + return + } + + // 临时保存上传的文件到挂载目录,避免 /tmp 跨分区移动或权限问题 + tmpDir := filepath.Join(constant.DataDir, "tmp") + os.MkdirAll(tmpDir, 0755) + tmpFile := filepath.Join(tmpDir, file.Filename) + + if err := ctx.SaveUploadedFile(file, tmpFile); err != nil { + utils.ServerError(ctx, "保存临时文件失败") + return + } + defer os.Remove(tmpFile) // 自动清理临时文件 + + webuiName, err := c.webuiService.ExtractWebUI(tmpFile) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"message": "WebUI上传成功", "webui": webuiName}) +} + +// SetActiveWebUI 切换活动WebUI +func (c *WebUIController) SetActiveWebUI(ctx *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "无效的请求参数") + return + } + + if err := c.webuiService.SetActiveWebUI(req.Name); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"message": "WebUI已切换成功,部分页面可能需要刷新"}) +} + +// DeleteWebUI 删除自定义WebUI +func (c *WebUIController) DeleteWebUI(ctx *gin.Context) { + name := ctx.Param("name") + if name == "" { + utils.BadRequest(ctx, "未提供WebUI名称") + return + } + + if err := c.webuiService.DeleteWebUI(name); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{"message": "WebUI已删除"}) +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..5fe2483 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,130 @@ +package database + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/systime" + + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +var DB *gorm.DB +var DBConfig *Config + +type Config struct { + Type string // sqlite, mysql, postgres + Host string + Port int + User string + Password string + DBName string + Path string // for sqlite + DSN string // for mysql/mariadb unix socket or custom dsn + SSLMode string // postgres: disable/require/verify-ca/verify-full; mysql: true/skip-verify +} + +func Init(cfg *Config) error { + var err error + DBConfig = cfg + // 设置东八区时区 + loc := systime.CST + time.Local = loc + + dsn, err := buildDSN(cfg) + if err != nil { + return err + } + + var dialector gorm.Dialector + + switch cfg.Type { + case "sqlite": + dialector = sqlite.Open(dsn) + case "mysql": + dialector = mysql.Open(dsn) + case "postgres": + dialector = postgres.Open(dsn) + default: + return fmt.Errorf("unsupported database type: %s", cfg.Type) + } + + newLogger := gormlogger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer + gormlogger.Config{ + SlowThreshold: time.Millisecond * 500, // 慢 SQL 阈值,默认是 200ms,这里改为 500ms + LogLevel: gormlogger.Warn, // 日志级别 + IgnoreRecordNotFoundError: true, // 忽略 ErrRecordNotFound(找不到记录)错误 + Colorful: true, // 禁用彩色打印 + }, + ) + + DB, err = gorm.Open(dialector, &gorm.Config{ + Logger: newLogger, + NowFunc: func() time.Time { + return time.Now().In(loc) + }, + }) + if err != nil { + return fmt.Errorf("failed to connect database: %w", err) + } + + logger.Infof("[Database] 已连接 %s 数据库 (时区: Asia/Shanghai)", cfg.Type) + + // SQLite 特殊优化:开启 WAL 模式,提升并发性能 + if cfg.Type == "sqlite" { + sqlDB, _ := DB.DB() + if sqlDB != nil { + sqlDB.SetMaxOpenConns(1) // SQLite 只允许单写连接 + sqlDB.Exec("PRAGMA journal_mode=WAL") + sqlDB.Exec("PRAGMA synchronous=NORMAL") + } + } + + return nil +} + +func AutoMigrate(models ...interface{}) error { + return DB.AutoMigrate(models...) +} + +func GetDB() *gorm.DB { + return DB +} + +func buildDSN(cfg *Config) (string, error) { + switch cfg.Type { + case "sqlite": + return cfg.Path + "?_busy_timeout=5000", nil + case "mysql": + if cfg.DSN != "" { + return cfg.DSN, nil + } + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Asia%%2FShanghai", + cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName) + if cfg.SSLMode != "" { + dsn += "&tls=" + cfg.SSLMode + } + return dsn, nil + case "postgres": + if cfg.DSN != "" { + return cfg.DSN, nil + } + sslMode := cfg.SSLMode + if sslMode == "" { + sslMode = "disable" + } + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, sslMode) + return dsn, nil + default: + return "", fmt.Errorf("unsupported database type: %s", cfg.Type) + } +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..4fc95e2 --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,304 @@ +package database + +import ( + "crypto/md5" + "encoding/hex" + "reflect" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/rs/xid" +) + +var allModels = []interface{}{ + &models.AppLog{}, + &models.User{}, + &models.Task{}, + &models.TaskLog{}, + &models.Script{}, + &models.EnvironmentVariable{}, + &models.Setting{}, + &models.SendStats{}, + &models.Dependency{}, + &models.Agent{}, + &models.AgentToken{}, + &models.Language{}, + &models.NotifyWay{}, + &models.NotifyBinding{}, + &models.DataRelation{}, + &models.DataStorage{}, + &models.InterconnectNode{}, +} + +func Migrate() error { + // 1. 自动指纹识别,大幅提升远程数据库启动进度 + sig := getModelSignature(allModels) + if DB.Migrator().HasTable(&models.Setting{}) { + var sigSetting models.Setting + res := DB.Where(&models.Setting{Section: constant.SectionSystem, Key: constant.KeySchemaSignature}).Limit(1).Find(&sigSetting) + if res.RowsAffected > 0 && string(sigSetting.Value) == sig { + logger.Info("[Database] 模型指纹一致,跳过自动表结构同步") + + // 即使表结构一致,也要执行后置数据迁移(内部有幂等检查),防止有漏网之鱼 + logger.Info("[Database] 正在执行后置数据迁移...") + if err := postMigrations(); err != nil { + logger.Warnf("[Database] 后置数据迁移警告: %v", err) + } + return nil + } + } + + // 执行前置结构迁移 + logger.Info("[Database] 正在执行前置结构迁移与表结构同步...") + if err := preMigrations(); err != nil { + logger.Warnf("[Database] 前置结构迁移警告: %v", err) + } + + logger.Infof("[Database] 正在同步 %d 个数据模型的表结构...", len(allModels)) + if err := AutoMigrate(allModels...); err != nil { + return err + } + + // 执行后置数据迁移,依赖完整的表结构 + logger.Info("[Database] 正在执行后置数据迁移...") + if err := postMigrations(); err != nil { + logger.Warnf("[Database] 后置数据迁移警告: %v", err) + } + + // 3. 更新指纹记录 + if DB.Migrator().HasTable(&models.Setting{}) { + var sigSetting models.Setting + res := DB.Where(&models.Setting{Section: constant.SectionSystem, Key: constant.KeySchemaSignature}).Limit(1).Find(&sigSetting) + if res.RowsAffected > 0 { + DB.Model(&sigSetting).Update("value", models.BigText(sig)) + } else { + DB.Create(&models.Setting{ + ID: constant.IDSchemaSignature, + Section: constant.SectionSystem, + Key: constant.KeySchemaSignature, + Value: models.BigText(sig), + }) + } + } + + return nil +} + +// getModelSignature 生成数据模型的结构指纹 +func getModelSignature(models []interface{}) string { + var sb strings.Builder + // 包含表前缀,确保前缀变更时也能触发迁移 + sb.WriteString(constant.TablePrefix) + for _, m := range models { + t := reflect.TypeOf(m) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + sb.WriteString(t.Name()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.Anonymous { + continue + } + sb.WriteString(f.Name) + sb.WriteString(f.Type.String()) + sb.WriteString(f.Tag.Get("gorm")) + } + } + hash := md5.Sum([]byte(sb.String())) + return hex.EncodeToString(hash[:]) +} + +// preMigrations 前置结构迁移,处理 AutoMigrate 无法自动解决的变更 +func preMigrations() error { + // 检查 ql_tokens 表是否存在 + if DB.Migrator().HasTable(constant.TableMigrateQlTokens) { + // 如果 code 列存在,且 token 列不存在,则重命名 + if DB.Migrator().HasColumn(&models.AgentToken{}, constant.ColumnMigrateQlTokenCode) { + if err := DB.Migrator().RenameColumn(&models.AgentToken{}, constant.ColumnMigrateQlTokenCode, constant.ColumnMigrateQlTokenToken); err != nil { + logger.Debugf("[Database] 重命名 ql_tokens.code 失败: %v", err) + } + } + } + // 移除 deps 表中的 type 字段(如果存在) + if DB.Migrator().HasColumn(&models.Dependency{}, constant.ColumnMigrateDependencyType) { + if err := DB.Migrator().DropColumn(&models.Dependency{}, constant.ColumnMigrateDependencyType); err != nil { + logger.Debugf("[Database] 移除 deps.type 字段失败: %v", err) + } else { + logger.Infof("[Database] 已成功移除 deps 表中的 type 字段") + } + } + + return nil +} + +// postMigrations 数据后置迁移,用于需要依赖完整表结构的数据搬运 +func postMigrations() error { + // 迁移任务标签到通用的数据关联表中 + migrateTaskTags() + // 迁移任务绑定的环境变量到通用的数据关联表中 + migrateTaskEnvs() + return nil +} + +// migrateTaskEnvs 迁移旧的任务绑定环境变量到通用数据关联表 +func migrateTaskEnvs() { + // 检查 settings 表中是否已经记录了迁移状态 + if DB.Migrator().HasTable(&models.Setting{}) { + var setting models.Setting + res := DB.Where(&models.Setting{Section: constant.SectionSystem, Key: constant.KeyTaskEnvsMigrated}).Limit(1).Find(&setting) + if res.RowsAffected > 0 && string(setting.Value) == "true" { + return + } + } + + if !DB.Migrator().HasColumn(&models.Task{}, "envs") { + markTaskEnvsMigrated() + return + } + logger.Infof("[Database] 正在迁移旧任务环境变量绑定...") + + type TaskMigration struct { + ID string + Envs models.BigText + } + var tasks []TaskMigration + DB.Table((&models.Task{}).TableName()).Select("id, envs").Where("envs IS NOT NULL AND envs != ?", "").Find(&tasks) + + for _, task := range tasks { + envs := strings.Split(string(task.Envs), ",") + for _, envID := range envs { + envID = strings.TrimSpace(envID) + if envID == "" { + continue + } + var count int64 + DB.Model(&models.DataRelation{}).Where("data_id = ? AND relate_id = ? AND type = ?", task.ID, envID, constant.RelationTypeTaskEnv).Count(&count) + if count == 0 { + relation := models.DataRelation{ + ID: xid.New().String(), + DataID: task.ID, + RelateID: envID, + Type: constant.RelationTypeTaskEnv, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + DB.Create(&relation) + } + } + } + + // if err := DB.Migrator().DropColumn(&models.Task{}, "envs"); err != nil { + // logger.Debugf("[Database] 移除 bh_tasks.envs 字段失败: %v", err) + // } else { + // logger.Infof("[Database] 成功迁移 %d 个环境变量绑定的任务,并删除了旧 envs 字段", len(tasks)) + // } + logger.Infof("[Database] 成功迁移 %d 个环境变量绑定的任务", len(tasks)) + markTaskEnvsMigrated() +} + +func markTaskEnvsMigrated() { + if !DB.Migrator().HasTable(&models.Setting{}) { + return + } + var setting models.Setting + res := DB.Where(&models.Setting{Section: constant.SectionSystem, Key: constant.KeyTaskEnvsMigrated}).Limit(1).Find(&setting) + if res.RowsAffected > 0 { + DB.Model(&setting).Update("value", models.BigText("true")) + } else { + DB.Create(&models.Setting{ + ID: xid.New().String(), + Section: constant.SectionSystem, + Key: constant.KeyTaskEnvsMigrated, + Value: models.BigText("true"), + }) + } +} + +// migrateTaskTags 迁移旧的任务标签到通用数据关联表 +func migrateTaskTags() { + // 检查 settings 表中是否已经记录了迁移状态 + if DB.Migrator().HasTable(&models.Setting{}) { + var setting models.Setting + res := DB.Where(&models.Setting{Section: constant.SectionSystem, Key: constant.KeyTaskTagsMigrated}).Limit(1).Find(&setting) + if res.RowsAffected > 0 && string(setting.Value) == "true" { + return + } + } + + if !DB.Migrator().HasColumn(&models.Task{}, "tags") { + markTaskTagsMigrated() + return + } + logger.Infof("[Database] 正在迁移旧任务标签...") + + type TaskMigration struct { + ID string + Tags string + } + var tasks []TaskMigration + DB.Table((&models.Task{}).TableName()).Select("id, tags").Where("tags != ?", "").Find(&tasks) + + for _, task := range tasks { + tags := strings.Split(task.Tags, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + var storage models.DataStorage + res := DB.Where("type = ? AND name = ?", constant.RelationTypeTaskTag, tag).Limit(1).Find(&storage) + if res.RowsAffected == 0 { + storage = models.DataStorage{ + ID: xid.New().String(), + Type: constant.RelationTypeTaskTag, + Name: tag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + DB.Create(&storage) + } + var count int64 + DB.Model(&models.DataRelation{}).Where("data_id = ? AND relate_id = ? AND type = ?", task.ID, storage.ID, constant.RelationTypeTaskTag).Count(&count) + if count == 0 { + relation := models.DataRelation{ + ID: xid.New().String(), + DataID: task.ID, + RelateID: storage.ID, + Type: constant.RelationTypeTaskTag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + DB.Create(&relation) + } + } + } + + // if err := DB.Migrator().DropColumn(&models.Task{}, "tags"); err != nil { + // logger.Debugf("[Database] 移除 bh_tasks.tags 字段失败: %v", err) + // } else { + // logger.Infof("[Database] 成功迁移 %d 个带有标签的任务,并删除了旧 tags 字段", len(tasks)) + // } + logger.Infof("[Database] 成功迁移 %d 个带有标签的任务", len(tasks)) + markTaskTagsMigrated() +} + +func markTaskTagsMigrated() { + if !DB.Migrator().HasTable(&models.Setting{}) { + return + } + var setting models.Setting + res := DB.Where(&models.Setting{Section: constant.SectionSystem, Key: constant.KeyTaskTagsMigrated}).Limit(1).Find(&setting) + if res.RowsAffected > 0 { + DB.Model(&setting).Update("value", models.BigText("true")) + } else { + DB.Create(&models.Setting{ + ID: xid.New().String(), + Section: constant.SectionSystem, + Key: constant.KeyTaskTagsMigrated, + Value: models.BigText("true"), + }) + } +} diff --git a/internal/eventbus/bus.go b/internal/eventbus/bus.go new file mode 100644 index 0000000..bcf3866 --- /dev/null +++ b/internal/eventbus/bus.go @@ -0,0 +1,50 @@ +package eventbus + +import "sync" + +// Event 统一定义的事件体内包含的数据 +type Event struct { + Type string + Payload interface{} +} + +// Handler 事件具体的执行句柄 +type Handler func(event Event) + +// Subscriber 事件订阅者接口,各业务 Service 若关注系统总线可实现此接口 +type Subscriber interface { + SubscribeEvents(bus *EventBus) +} + +type EventBus struct { + handlers map[string][]Handler + mu sync.RWMutex +} + +func New() *EventBus { + return &EventBus{ + handlers: make(map[string][]Handler), + } +} + +// Subscribe 注册订阅事件 +func (bus *EventBus) Subscribe(eventType string, handler Handler) { + bus.mu.Lock() + defer bus.mu.Unlock() + bus.handlers[eventType] = append(bus.handlers[eventType], handler) +} + +// Publish 异步抛出事件 +func (bus *EventBus) Publish(event Event) { + bus.mu.RLock() + handlers := bus.handlers[event.Type] + bus.mu.RUnlock() + + for _, handler := range handlers { + // 采用 Goroutine 异步不阻塞核心主线 + go handler(event) + } +} + +// 全局唯一的事件总线实例 +var DefaultBus = New() diff --git a/internal/executor/cron.go b/internal/executor/cron.go new file mode 100644 index 0000000..f0e76fd --- /dev/null +++ b/internal/executor/cron.go @@ -0,0 +1,239 @@ +package executor + +import ( + "fmt" + "math/rand" + "strings" + "sync" + "time" + + "github.com/engigu/taskpool/internal/systime" + + "github.com/robfig/cron/v3" +) + +// 东八区时区(默认) +var defaultLocation = systime.CST + +// CronManager 统一的任务调度管理器 +type CronManager struct { + cron *cron.Cron + scheduler *Scheduler + entryMap map[string]cron.EntryID // task ID -> cron entry ID + mu sync.RWMutex + logger SchedulerLogger + OnTrigger func(task CronTask) *ExecutionRequest // 任务触发时的请求构造工厂 +} + +// NewCronManager 创建一个新的计划任务管理器 +func NewCronManager(scheduler *Scheduler) *CronManager { + // 使用秒级精度的 cron parser + c := cron.New(cron.WithSeconds(), cron.WithLocation(defaultLocation)) + + m := &CronManager{ + cron: c, + scheduler: scheduler, + entryMap: make(map[string]cron.EntryID), + logger: &DefaultLogger{}, + } + + if scheduler != nil && scheduler.logger != nil { + m.logger = scheduler.logger + } + + return m +} + +// SetLogger 设置自定义日志实现 +func (m *CronManager) SetLogger(logger SchedulerLogger) { + m.mu.Lock() + defer m.mu.Unlock() + m.logger = logger +} + +// SetScheduler 更新关联的调度器实例 +func (m *CronManager) SetScheduler(scheduler *Scheduler) { + m.mu.Lock() + defer m.mu.Unlock() + m.scheduler = scheduler +} + +// Start 启动调度器 +func (m *CronManager) Start() { + m.cron.Start() + m.logger.Infof("[CronManager] 调度管理服务已启动") +} + +// Stop 停止调度器 +func (m *CronManager) Stop() { + ctx := m.cron.Stop() + <-ctx.Done() + m.logger.Infof("[CronManager] 调度管理服务已停止") +} + +// AddTask 添加或更新计划任务 +func (m *CronManager) AddTask(task CronTask) error { + m.mu.Lock() + defer m.mu.Unlock() + + taskID := task.GetID() + + // 如果已存在,先移除旧的 + if entryID, exists := m.entryMap[taskID]; exists { + m.cron.Remove(entryID) + delete(m.entryMap, taskID) + } + + // 准备任务执行函数 + cmd := task.GetCommand() + name := task.GetName() + timeout := task.GetTimeout() + workDir := task.GetWorkDir() + envs := task.GetEnvs() + languages := task.GetLanguages() + useMise := task.UseMise() + secrets := task.GetSecrets() + + schedule := strings.TrimSpace(task.GetSchedule()) + entryID, err := m.cron.AddFunc(schedule, func() { + defer func() { + if r := recover(); r != nil { + m.logger.Errorf("[CronManager] 任务 #%s 执行过程中发生 Panic: %v", taskID, r) + } + }() + + // 构造执行请求的 Builder + reqBuilder := func() *ExecutionRequest { + if m.OnTrigger != nil { + return m.OnTrigger(task) + } + return &ExecutionRequest{ + TaskID: taskID, + Name: name, + Command: cmd, + PreCommand: task.GetPreCommand(), + PostCommand: task.GetPostCommand(), + Type: TaskTypeCron, + Timeout: timeout, + WorkDir: workDir, + Envs: func() []string { + if vars := task.GetEnvVars(); len(vars) > 0 { + return vars + } + return ParseEnvVars(envs) + }(), + Secrets: secrets, + Languages: languages, + UseMise: useMise, + } + } + + randomRange := task.GetRandomRange() + if randomRange > 0 && m.scheduler != nil { + // 生成 0 到 randomRange 之间的随机秒数 + delaySeconds := rand.Intn(randomRange) + delay := time.Duration(delaySeconds) * time.Second + m.logger.Infof("[CronManager] 任务 %s (#%s) 将随机延迟 %v (范围: %ds) 后入队", name, taskID, delay, randomRange) + + // 使用调度器的延时投递功能,不阻塞当前 Cron 协程 + m.scheduler.EnqueueDelayed(delay, reqBuilder) + } else { + m.logger.Infof("[CronManager] 触发计划任务: %s (#%s)", name, taskID) + if m.scheduler != nil { + m.scheduler.EnqueueOrExecute(reqBuilder()) + } + } + + // 触发下次运行时间更新事件 + m.triggerNextRunEvent(taskID, &ExecutionRequest{TaskID: taskID}) + }) + + if err != nil { + m.logger.Errorf("[CronManager] 添加任务失败 #%s: %v", taskID, err) + return err + } + + m.entryMap[taskID] = entryID + m.logger.Infof("[CronManager] 已添加调度: %s (#%s) [%s]", name, taskID, task.GetSchedule()) + + // 初始触发一次下次运行时间通知 + go func() { + req := &ExecutionRequest{ + TaskID: taskID, + Name: name, + Type: TaskTypeCron, + UseMise: task.UseMise(), + } + m.triggerNextRunEvent(taskID, req) + }() + + return nil +} + +// RemoveTask 移除计划任务 +func (m *CronManager) RemoveTask(taskID string) { + m.mu.Lock() + defer m.mu.Unlock() + + if entryID, exists := m.entryMap[taskID]; exists { + m.cron.Remove(entryID) + delete(m.entryMap, taskID) + m.logger.Infof("[CronManager] 任务已移除 #%s", taskID) + } +} + +// triggerNextRunEvent 触发下次运行时间更新事件 +func (m *CronManager) triggerNextRunEvent(taskID string, req *ExecutionRequest) { + m.mu.RLock() + entryID, exists := m.entryMap[taskID] + m.mu.RUnlock() + + if !exists { + return + } + + entry := m.cron.Entry(entryID) + if !entry.Next.IsZero() && m.scheduler != nil && m.scheduler.handler != nil { + m.scheduler.handler.OnCronNextRun(req, entry.Next) + } +} + +// ValidateCron 校验 Cron 表达式 +func (m *CronManager) ValidateCron(expression string) error { + expression = strings.TrimSpace(expression) + if expression == "" { + return fmt.Errorf("cron 表达式不能为空") + } + + // 如果不是以 @ 开头的描述符,检查位数 + if !strings.HasPrefix(expression, "@") { + fields := strings.Fields(expression) + if len(fields) != 6 { + return fmt.Errorf("cron 表达式必须为 6 位 (秒 分 时 日 月 周)") + } + } + + parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + _, err := parser.Parse(expression) + return err +} + +// GetEntry 获取任务详情 +func (m *CronManager) GetEntry(taskID string) (cron.Entry, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + entryID, exists := m.entryMap[taskID] + if !exists { + return cron.Entry{}, false + } + + return m.cron.Entry(entryID), true +} + +// GetScheduledCount 获取已调度任务总数 +func (m *CronManager) GetScheduledCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.entryMap) +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..024e871 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,388 @@ +package executor + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/creack/pty" + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/utils" +) + +// Task 任务基础接口 +type Task interface { + GetID() string + GetName() string + GetCommand() string + GetPreCommand() string + GetPostCommand() string + GetTimeout() int + GetWorkDir() string + GetEnvs() string + GetEnvVars() []string + GetLanguages() []map[string]string + GetUseMise() bool +} + +// CronTask 计划任务接口 +type CronTask interface { + Task + GetSchedule() string + UseMise() bool + GetSecrets() []string + GetRandomRange() int +} + +// Request 任务执行请求 +type Request struct { + Command string + PreCommand string + PostCommand string + WorkDir string + Envs []string + Timeout int // 任务超时时间(分钟) + Languages []map[string]string + UseMise bool +} + +// Result 任务执行结果 +type Result struct { + Output string + Error string + Status string // 状态: success, failed + Duration int64 // 毫秒 + ExitCode int + StartTime time.Time + EndTime time.Time +} + +// Hooks 执行钩子接口 +type Hooks interface { + // PreExecute 执行前钩子,返回日志ID和错误 + PreExecute(ctx context.Context, req Request) (logID string, err error) + + // PostExecute 执行后钩子,处理日志压缩和记录更新 + PostExecute(ctx context.Context, logID string, result *Result) error + + // OnHeartbeat 执行中心跳钩子,用于更新实时状态 + OnHeartbeat(ctx context.Context, logID string, duration int64) error +} + +// Execute 执行命令(基础版本,不带钩子) +func Execute(ctx context.Context, req Request, stdout, stderr io.Writer) (*Result, error) { + return ExecuteWithHooks(ctx, req, stdout, stderr, nil) +} + +// ExecuteWithHooks 执行命令(带钩子支持) +func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer, hooks Hooks) (*Result, error) { + start := time.Now() + + // 演示模式拦截 + if constant.DemoMode { + logger.Warnf("[Executor] 演示模式下已拦截命令执行: %s", req.Command) + if stdout != nil { + stdout.Write([]byte("\r\n\033[1;33m[演示模式] 命令执行已跳过\033[0m\r\n")) + } + + // 仍然触发 PreExecute 以便流程完整 + var logID string + if hooks != nil { + logID, _ = hooks.PreExecute(ctx, req) + } + + result := &Result{ + Status: constant.TaskStatusFailed, + Output: "[演示模式] 该任务在演示模式下被禁用执行", + StartTime: start, + EndTime: time.Now(), + } + + if hooks != nil { + hooks.PostExecute(ctx, logID, result) + } + return result, nil + } + + // 2. 执行命令 + timeout := req.Timeout + var execCtx context.Context + var cancel context.CancelFunc + + if timeout > 0 { + execCtx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Minute) + } else { + execCtx, cancel = context.WithCancel(ctx) + } + defer cancel() + + // 如果指定使用 mise,则预先构建好带 mise 的命令,这样 PreExecute 记录的就是完整命令 + if req.UseMise { + utils.InjectNodePath(&req.Envs, req.Languages) + req.Command = utils.BuildMiseCommand(req.Command, req.Languages) + req.UseMise = false + } + + // 组合指令(如果存在前置或后置指令) + if req.PreCommand != "" || req.PostCommand != "" { + finalCmd := "" + if req.PreCommand != "" { + finalCmd += req.PreCommand + "\n" + } + finalCmd += req.Command + if req.PostCommand != "" { + finalCmd += "\n" + req.PostCommand + } + req.Command = finalCmd + } + + // 1. 执行前钩子 + var logID string + if hooks != nil { + id, err := hooks.PreExecute(ctx, req) + if err != nil { + return &Result{ + Status: constant.TaskStatusFailed, + Duration: 0, + ExitCode: 1, + StartTime: start, + EndTime: time.Now(), + }, err + } + logID = id + } + + shell, args := utils.GetShellCommand(req.Command) + cmd := exec.CommandContext(execCtx, shell, args...) + + usePty := runtime.GOOS != "windows" && stdout != nil && (stdout == stderr || stdout == io.Discard) + SetProcessGroupAndCancel(cmd, usePty) + + // 设置工作目录 + // 设置工作目录 + workDir := strings.TrimSpace(req.WorkDir) + if workDir != "" { + cmd.Dir = workDir + } + + // 设置环境变量(始终继承系统环境变量) + cmd.Env = os.Environ() + if len(req.Envs) > 0 { + cmd.Env = append(cmd.Env, req.Envs...) + } + // 强制注入终端环境标识及禁用输出缓冲的标志 + cmd.Env = append(cmd.Env, + "TERM=xterm", + "PYTHONUNBUFFERED=1", + "NODE_NO_WARNINGS=1", + ) + + var pipeWriter *os.File + var ptyFile *os.File + var copyDone chan struct{} + var err error + + var started bool + // 尝试开启 PTY 模式(Unix/macOS 且输出合并时) + if runtime.GOOS != "windows" && stdout != nil && (stdout == stderr || stdout == io.Discard) { + // 强制注入终端环境标识及禁用输出缓冲的标志,确保 PTY 模式下最佳实时性能 + cmd.Env = append(cmd.Env, + "TERM=xterm", + "PYTHONUNBUFFERED=1", + "NODE_NO_WARNINGS=1", + ) + f, ptyErr := pty.Start(cmd) + if ptyErr == nil { + logger.Infof("[Executor] #%s 启动于 PTY 模式", logID) + ptyFile = f + started = true + copyDone = make(chan struct{}) + go func() { + defer close(copyDone) + // io.Copy 对于 PTY 来说是最稳健且即时的流式拷贝 + io.Copy(stdout, f) + f.Close() + }() + } else { + logger.Errorf("[Executor] 任务 #%s PTY 启动失败: %v", logID, ptyErr) + } + } + + if !started { + // 如果 stdout 和 stderr 指针不一致,但在逻辑上我们知道它们是同一个 MultiWriter, + // 这里会显示为 Pipe 模式。 + if stdout != stderr && stdout != io.Discard { + logger.Debugf("[Executor] 任务 #%d stdout (%p) 和 stderr (%p) 不同,回退到 Pipe 模式。", logID, stdout, stderr) + } + logger.Infof("[Executor] #%s 启动于 Pipe 模式", logID) + if stdout != nil && stdout == stderr { + pr, pw, err := os.Pipe() + if err == nil { + cmd.Stdout = pw + cmd.Stderr = pw + pipeWriter = pw + copyDone = make(chan struct{}) + go func() { + io.Copy(stdout, pr) + pr.Close() + close(copyDone) + }() + } else { + cmd.Stdout = stdout + cmd.Stderr = stderr + } + } else { + cmd.Stdout = stdout + cmd.Stderr = stderr + } + + // 使用 cmd.Start() + Wait() 以便在后台处理心跳 + err = cmd.Start() + if err != nil { + if pipeWriter != nil { + pipeWriter.Close() + } + // 启动失败的处理 + end := time.Now() + result := &Result{ + Status: constant.TaskStatusFailed, + Duration: end.Sub(start).Milliseconds(), + ExitCode: 1, + StartTime: start, // 记录开始时间 + EndTime: end, + } + // 执行后钩子 + if hooks != nil { + result.Output += "\n[系统错误] " + err.Error() + hooks.PostExecute(ctx, logID, result) + } + return result, err + } + + // 在父进程中关闭写端,这样子进程退出后 pr 才会收到 EOF + if pipeWriter != nil { + pipeWriter.Close() + } + } else { + // PTY 模式下 cmd.Start() 已经在 pty.Start(cmd) 中调用过了 + } + + // 启动心跳协程 + done := make(chan struct{}) + go func() { + // 每3秒一次心跳 + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + if hooks != nil { + hooks.OnHeartbeat(ctx, logID, time.Since(start).Milliseconds()) + } + } + } + }() + + // 等待命令完成 + err = cmd.Wait() + close(done) // 停止心跳 + + // PTY 模式下需要显式关闭 + if ptyFile != nil { + ptyFile.Close() + } + + // 等待日志复制完成 + if copyDone != nil { + <-copyDone + } + + end := time.Now() + + result := &Result{ + StartTime: start, + EndTime: end, + Duration: end.Sub(start).Milliseconds(), + } + + if err != nil { + result.Status = constant.TaskStatusFailed + result.Error = err.Error() + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = 1 + } + } else { + result.Status = constant.TaskStatusSuccess + result.ExitCode = 0 + } + + // 3. 执行后钩子 + if hooks != nil { + if hookErr := hooks.PostExecute(ctx, logID, result); hookErr != nil { + // 记录钩子错误但不影响执行结果 + result.Output += "\n[钩子错误] " + hookErr.Error() + } + } + + return result, err +} + +// ParseEnvVars 解析环境变量字符串 "KEY1=VALUE1,KEY2=VALUE2" +func ParseEnvVars(envStr string) []string { + if envStr == "" { + return nil + } + + pairs := strings.Split(envStr, ",") + result := make([]string, 0, len(pairs)) + + for _, pair := range pairs { + if pair == "" { + continue + } + // 解码特殊字符 + pair = strings.ReplaceAll(pair, "{{COMMA}}", ",") + pair = strings.ReplaceAll(pair, "{{EQUAL}}", "=") + pair = strings.ReplaceAll(pair, "{{NL}}", "\n") + result = append(result, pair) + } + + return result +} + +// FormatEnvVars 将环境变量列表格式化为逗号分隔的字符串 "KEY1=VALUE1,KEY2=VALUE2" +// 会对 , 和 = 以及换行符进行转义 +func FormatEnvVars(envs []string) string { + if len(envs) == 0 { + return "" + } + + pairs := make([]string, 0, len(envs)) + for _, pair := range envs { + // 寻找第一个等号 + idx := strings.Index(pair, "=") + if idx == -1 { + continue + } + name := pair[:idx] + value := pair[idx+1:] + + // 转义特殊字符 + encodedValue := strings.ReplaceAll(value, ",", "{{COMMA}}") + encodedValue = strings.ReplaceAll(encodedValue, "=", "{{EQUAL}}") + encodedValue = strings.ReplaceAll(encodedValue, "\n", "{{NL}}") + pairs = append(pairs, fmt.Sprintf("%s=%s", name, encodedValue)) + } + + return strings.Join(pairs, ",") +} diff --git a/internal/executor/proc_unix.go b/internal/executor/proc_unix.go new file mode 100644 index 0000000..959c875 --- /dev/null +++ b/internal/executor/proc_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package executor + +import ( + "os/exec" + "syscall" +) + +func SetProcessGroupAndCancel(cmd *exec.Cmd, usePty bool) { + if !usePty { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } + cmd.Cancel = func() error { + if cmd.Process != nil { + // Kill the entire process group by sending SIGKILL to negative PID + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } +} diff --git a/internal/executor/proc_windows.go b/internal/executor/proc_windows.go new file mode 100644 index 0000000..ddfc1c2 --- /dev/null +++ b/internal/executor/proc_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package executor + +import ( + "fmt" + "os/exec" +) + +func SetProcessGroupAndCancel(cmd *exec.Cmd, usePty bool) { + cmd.Cancel = func() error { + if cmd.Process != nil { + killCmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", cmd.Process.Pid)) + return killCmd.Run() + } + return nil + } +} diff --git a/internal/executor/scheduler.go b/internal/executor/scheduler.go new file mode 100644 index 0000000..1364826 --- /dev/null +++ b/internal/executor/scheduler.go @@ -0,0 +1,690 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/utils" +) + +// safeBuffer 一个线程安全的字节缓冲区,用于合并 stdout 和 stderr +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (s *safeBuffer) Write(p []byte) (n int, err error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Write(p) +} + +func (s *safeBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.String() +} + +// SchedulerConfig 调度器配置 +type SchedulerConfig struct { + WorkerCount int // Worker 数量 + QueueSize int // 队列大小 + RateInterval time.Duration // 速率限制间隔 + Verbose bool // 是否开启详细日志 + StrictQueue bool // 是否开启严格排队(满时拒绝执行,不降级直接执行) +} + +// TaskType 任务类型 +type TaskType string + +const ( + TaskTypeCron TaskType = "cron" // 计划任务 + TaskTypeManual TaskType = "manual" // 手动任务 + TaskTypeSystem TaskType = "system" // 系统任务 +) + +// TaskStatus 任务状态 +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = TaskStatus(constant.TaskStatusPending) // 等待中 + TaskStatusRunning TaskStatus = TaskStatus(constant.TaskStatusRunning) // 运行中 + TaskStatusSuccess TaskStatus = TaskStatus(constant.TaskStatusSuccess) // 成功 + TaskStatusFailed TaskStatus = TaskStatus(constant.TaskStatusFailed) // 失败 + TaskStatusTimeout TaskStatus = TaskStatus(constant.TaskStatusTimeout) // 超时 + TaskStatusCancelled TaskStatus = TaskStatus(constant.TaskStatusCancelled) // 已取消 +) + +// ExecutionRequest 执行请求(标准接口) +type ExecutionRequest struct { + TaskID string // 任务 ID + LogID string // 日志 ID + Name string // 任务名称 + Type TaskType // 任务类型 + Command string // 命令 + MaskedCommand string // 脱敏后的命令(用于日志和展示) + PreCommand string // 前置命令 + PostCommand string // 后置命令 + WorkDir string // 工作目录 + Envs []string // 环境变量 + Secrets []string // 需要脱敏的密码 + Timeout int // 超时时间(分钟) + Languages []map[string]string // 语言环境配置 + UseMise bool // 是否使用 mise + Metadata ExecutionMetadata // 额外元数据 +} + +// ExecutionMetadata 执行额外元数据 +type ExecutionMetadata struct { + GoID int64 // 关联的 goroutine ID + RetryIndex int // 当前重试索引 +} + +// ExecutionResult 执行结果(标准接口) +type ExecutionResult struct { + TaskID string // 任务 ID + LogID string // 日志 ID + Success bool // 是否成功 + Output string // 输出内容 + Error string // 错误信息 + Status string // 状态: success, failed, timeout, cancelled + Duration int64 // 执行时长(毫秒) + ExitCode int // 退出码 + StartTime time.Time // 开始时间 + EndTime time.Time // 结束时间 +} + +// SchedulerEventHandler 调度器事件处理器(标准接口) +// 主服务端和 Agent 端通过实现不同的 Handler 来处理事件 +type SchedulerEventHandler interface { + // OnTaskScheduled 任务被调度(加入队列)时触发 + OnTaskScheduled(req *ExecutionRequest) + + // OnTaskExecuting 任务准备开始执行时触发 + // 返回 stdout/stderr 写入器用于实时日志推送 + // 主服务端:返回 TinyLog 写入器(写入本地文件) + // Agent 端:返回 WebSocket 写入器(实时推送到主服务) + OnTaskExecuting(req *ExecutionRequest) (stdout, stderr io.Writer, err error) + + // OnTaskStarted 任务实际开始运行(已经过了队列等待和速率限制) + OnTaskStarted(req *ExecutionRequest) + + // OnTaskCompleted 任务执行完成时触发 + // 主服务端:压缩日志、更新数据库、清理旧日志 + // Agent 端:通过 WebSocket 发送执行结果到主服务 + OnTaskCompleted(req *ExecutionRequest, result *ExecutionResult) + + // OnTaskFailed 任务执行失败时触发 + OnTaskFailed(req *ExecutionRequest, err error) + + // OnCronNextRun 计划任务下次运行时间更新时触发 + OnCronNextRun(req *ExecutionRequest, nextRun time.Time) + + // OnTaskHeartbeat 任务执行心跳(用于更新实时耗时等) + OnTaskHeartbeat(req *ExecutionRequest, duration int64) +} + +// SchedulerLogger 日志接口(允许自定义日志实现) +type SchedulerLogger interface { + Infof(format string, args ...interface{}) + Warnf(format string, args ...interface{}) + Errorf(format string, args ...interface{}) +} + +// DefaultLogger 默认日志实现(使用 fmt) +type DefaultLogger struct{} + +func (l *DefaultLogger) Infof(format string, args ...interface{}) { + fmt.Printf("[INFO] "+format+"\n", args...) +} +func (l *DefaultLogger) Warnf(format string, args ...interface{}) { + fmt.Printf("[WARN] "+format+"\n", args...) +} +func (l *DefaultLogger) Errorf(format string, args ...interface{}) { + fmt.Printf("[ERROR] "+format+"\n", args...) +} + +// schedulerHooksAdapter 适配器:将 executor.Hooks 映射到 SchedulerEventHandler +type schedulerHooksAdapter struct { + handler SchedulerEventHandler + req *ExecutionRequest +} + +func (h *schedulerHooksAdapter) PreExecute(ctx context.Context, req Request) (string, error) { + return h.req.LogID, nil +} + +func (h *schedulerHooksAdapter) PostExecute(ctx context.Context, logID string, result *Result) error { + return nil +} + +func (h *schedulerHooksAdapter) OnHeartbeat(ctx context.Context, logID string, duration int64) error { + if h.handler != nil { + h.handler.OnTaskHeartbeat(h.req, duration) + } + return nil +} + +// TaskExecutor 定义任务执行函数签名 +type TaskExecutor func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error) + +// WorkerStatus 定义并发池中单个 Worker 的状态 +type WorkerStatus struct { + ID int `json:"id"` + Status string `json:"status"` // 状态: "idle" 或 "running" + TaskID string `json:"task_id,omitempty"` + TaskName string `json:"task_name,omitempty"` + StartTime int64 `json:"start_time,omitempty"` // 开始时间戳 (秒) + Duration int64 `json:"duration,omitempty"` // 已运行时长 (秒) +} + +// Scheduler 统一调度器(独立组件,可在主服务和 Agent 中复用) +// 调度器本身只负责队列管理和任务调度,具体的执行逻辑和事件处理由 Handler 实现 +type Scheduler struct { + config SchedulerConfig + handler SchedulerEventHandler + executor TaskExecutor + taskQueue chan *ExecutionRequest + rateLimiter <-chan time.Time + stopCh chan struct{} + wg sync.WaitGroup + mu sync.RWMutex + logger SchedulerLogger + runningTasks map[string]context.CancelFunc // 记录运行中的任务,用于停止 (TaskID -> CancelFunc) + runningExecs map[string]context.CancelFunc // 记录运行中的执行,用于停止 (LogID -> CancelFunc) + + workers []WorkerStatus + workerMu sync.RWMutex +} + +// NewScheduler 创建调度器 +func NewScheduler(config SchedulerConfig, handler SchedulerEventHandler) *Scheduler { + if config.WorkerCount <= 0 { + config.WorkerCount = 4 + } + if config.WorkerCount > 1000 { + config.WorkerCount = 1000 + } + if config.QueueSize <= 0 { + config.QueueSize = 100 + } + if config.QueueSize > 50000 { + config.QueueSize = 50000 + } + if config.RateInterval <= 0 { + config.RateInterval = 200 * time.Millisecond + } + + s := &Scheduler{ + config: config, + handler: handler, + executor: func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error) { + hooks := &schedulerHooksAdapter{handler: handler, req: req} + return ExecuteWithHooks(ctx, Request{ + Command: req.Command, + PreCommand: req.PreCommand, + PostCommand: req.PostCommand, + WorkDir: req.WorkDir, + Envs: req.Envs, + Timeout: req.Timeout, + Languages: req.Languages, + UseMise: req.UseMise, + }, stdout, stderr, hooks) + }, + taskQueue: make(chan *ExecutionRequest, config.QueueSize), + rateLimiter: time.Tick(config.RateInterval), + stopCh: make(chan struct{}), + logger: &DefaultLogger{}, + runningTasks: make(map[string]context.CancelFunc), + runningExecs: make(map[string]context.CancelFunc), + workers: make([]WorkerStatus, config.WorkerCount), + } + + for i := 0; i < config.WorkerCount; i++ { + s.workers[i] = WorkerStatus{ + ID: i, + Status: "idle", + } + } + + return s +} + +// SetLogger 设置自定义日志实现 +func (s *Scheduler) SetLogger(logger SchedulerLogger) { + s.mu.Lock() + defer s.mu.Unlock() + s.logger = logger +} + +// SetExecutor 设置任务执行器 +func (s *Scheduler) SetExecutor(executor TaskExecutor) { + s.mu.Lock() + defer s.mu.Unlock() + s.executor = executor +} + +// Start 启动调度器 +func (s *Scheduler) Start() { + for i := 0; i < s.config.WorkerCount; i++ { + s.wg.Add(1) + go s.worker(i) + } + s.logger.Infof("[Scheduler] 已启动") +} + +// Stop 停止调度器 +func (s *Scheduler) Stop() { + close(s.stopCh) + s.wg.Wait() + s.logger.Infof("[Scheduler] 已停止") +} + +// Enqueue 将任务加入队列 +func (s *Scheduler) Enqueue(req *ExecutionRequest) error { + select { + case s.taskQueue <- req: + if s.handler != nil { + s.handler.OnTaskScheduled(req) + } + return nil + default: + // 队列满,返回错误 + return fmt.Errorf("任务队列已满") + } +} + +// EnqueueOrExecute 将任务加入队列,如果队列满则直接执行 +func (s *Scheduler) EnqueueOrExecute(req *ExecutionRequest) { + select { + case s.taskQueue <- req: + // 成功入队 + if s.handler != nil { + s.handler.OnTaskScheduled(req) + } + default: + if s.config.StrictQueue { + s.logger.Errorf("[Scheduler] 任务队列已满,拒绝执行任务 %s", req.TaskID) + if s.handler != nil { + s.handler.OnTaskFailed(req, fmt.Errorf("任务队列已满,拒绝执行")) + } + } else { + // 队列满,直接执行(降级处理) + s.logger.Warnf("[Scheduler] 任务队列已满,直接执行任务 %s", req.TaskID) + go s.executeTask(req) + } + } +} + +// EnqueueDelayed 延迟将任务加入队列执行 +func (s *Scheduler) EnqueueDelayed(delay time.Duration, reqBuilder func() *ExecutionRequest) { + go func() { + select { + case <-time.After(delay): + if req := reqBuilder(); req != nil { + s.EnqueueOrExecute(req) + } + case <-s.stopCh: + // 调度器停止时取消延迟投递 + return + } + }() +} + +// ExecuteSync 同步执行任务(不经过队列) +func (s *Scheduler) ExecuteSync(req *ExecutionRequest) (*ExecutionResult, error) { + return s.executeTask(req) +} + +// worker 工作协程 +func (s *Scheduler) worker(id int) { + defer s.wg.Done() + + for { + select { + case <-s.stopCh: + return + case req := <-s.taskQueue: + func() { + defer func() { + if r := recover(); r != nil { + s.logger.Errorf("[Scheduler] Worker %d panic while processing task %s: %v", id, req.TaskID, r) + } + }() + // 速率限制 + <-s.rateLimiter + + func() { + // 恢复 worker 状态为空闲 + defer func() { + s.workerMu.Lock() + if id >= 0 && id < len(s.workers) { + s.workers[id].Status = "idle" + s.workers[id].TaskID = "" + s.workers[id].TaskName = "" + s.workers[id].StartTime = 0 + } + s.workerMu.Unlock() + }() + + // 更新 worker 状态为运行中 + s.workerMu.Lock() + if id >= 0 && id < len(s.workers) { + s.workers[id].Status = "running" + s.workers[id].TaskID = req.TaskID + s.workers[id].TaskName = req.Name + s.workers[id].StartTime = time.Now().Unix() + } + s.workerMu.Unlock() + + s.executeTask(req) + }() + }() + } + } +} + +// executeTask 执行任务(本地执行) +func (s *Scheduler) executeTask(req *ExecutionRequest) (*ExecutionResult, error) { + defer func() { + if r := recover(); r != nil { + s.logger.Errorf("[Scheduler] 任务 %s 执行过程中发生 Panic: %v", req.TaskID, r) + } + }() + start := time.Now() + + s.logger.Infof("[Scheduler] 开始执行: %s (#%s) [%s]", req.Name, req.TaskID, req.Type) + + // 演示模式拦截 + if constant.DemoMode { + s.logger.Infof("[Scheduler] 演示模式下已跳过任务 %s (%s) 的执行", req.TaskID, req.Name) + // 仍然触发 OnTaskExecuting 以便创建初始日志记录(业务层面的 Handler 会处理) + var stdout io.Writer + if s.handler != nil { + stdout, _, _ = s.handler.OnTaskExecuting(req) + } + + result := &ExecutionResult{ + TaskID: req.TaskID, + LogID: req.LogID, + Success: false, + Status: constant.TaskStatusFailed, + Error: "[演示模式] 该任务在演示模式下被禁用执行", + StartTime: start, + EndTime: time.Now(), + } + + if stdout != nil { + stdout.Write([]byte("\r\n\033[1;33m[演示模式] 定时任务/手动任务执行已跳过\033[0m\r\n")) + } + + if s.handler != nil { + s.handler.OnTaskCompleted(req, result) + } + return result, nil + } + + // 如果指定使用 mise,则预先构建好带 mise 的命令,这样 OnTaskExecuting 记录的就是完整命令 + if req.UseMise { + // 先注入 NODE_PATH (由于调度器会把 UseMise 置为 false,所以必须在这里提前处理) + utils.InjectNodePath(&req.Envs, req.Languages) + req.Command = utils.BuildMiseCommand(req.Command, req.Languages) + req.UseMise = false + } + // 确保系统级敏感信息(数据库地址、账号、密码等)始终在脱敏列表中 + allSecrets := append([]string{}, req.Secrets...) + allSecrets = append(allSecrets, utils.GetSystemSecrets()...) + + s.logger.Infof("[Scheduler] 命令: %s", utils.MaskSecrets(req.Command, allSecrets)) + + if s.config.Verbose { + workDir := req.WorkDir + if workDir == "" { + workDir, _ = os.Getwd() + } + s.logger.Infof("[Scheduler] 任务 #%s 进程 UID: %d, GID: %d", req.TaskID, os.Getuid(), os.Getgid()) + s.logger.Infof("[Scheduler] 任务 #%s 工作目录: %s", req.TaskID, workDir) + } + + // 1. 执行前事件:获取 stdout/stderr 写入器 + var stdout, stderr io.Writer + var err error + if s.handler != nil { + stdout, stderr, err = s.handler.OnTaskExecuting(req) + if err != nil { + s.logger.Errorf("[Scheduler] 任务 %s 执行前事件失败: %v", req.TaskID, err) + if s.handler != nil { + s.handler.OnTaskFailed(req, err) + } + return &ExecutionResult{ + TaskID: req.TaskID, + Success: false, + Status: constant.TaskStatusFailed, + Error: err.Error(), + Duration: 0, + ExitCode: 1, + StartTime: start, + EndTime: time.Now(), + }, err + } + } + + // 2. 准备输出缓冲区(使用合并缓冲区保证顺序) + var combinedBuf safeBuffer + var stdoutWriter, stderrWriter io.Writer + + if stdout != nil && stdout == stderr { + // 如果 stdout 和 stderr 是同一个对象,合并成一个 MultiWriter + // 这样后面 ExecuteWithHooks 才能识别出它们是同一个,从而开启 PTY 模式 + mw := io.MultiWriter(&combinedBuf, stdout) + stdoutWriter = mw + stderrWriter = mw + } else { + if stdout != nil { + stdoutWriter = io.MultiWriter(&combinedBuf, stdout) + } else { + stdoutWriter = &combinedBuf + } + + if stderr != nil { + stderrWriter = io.MultiWriter(&combinedBuf, stderr) + } else { + stderrWriter = &combinedBuf + } + } + + // 3. 实际开始执行事件 (经过队列和速率限制之后) + if s.handler != nil { + s.handler.OnTaskStarted(req) + } + + // 4. 执行命令(使用 executor.Execute) + // 创建带取消功能的上下文 + ctx, cancel := context.WithCancel(context.Background()) + if req.Timeout > 0 { + ctx, cancel = context.WithTimeout(ctx, time.Duration(req.Timeout)*time.Minute) + } + defer cancel() + + // 注册到运行中任务 + s.mu.Lock() + s.runningTasks[req.TaskID] = cancel + if req.LogID != "" { + s.runningExecs[req.LogID] = cancel + } + s.mu.Unlock() + + defer func() { + s.mu.Lock() + delete(s.runningTasks, req.TaskID) + if req.LogID != "" { + delete(s.runningExecs, req.LogID) + } + s.mu.Unlock() + }() + + execResult, execErr := s.executor(ctx, req, stdoutWriter, stderrWriter) + + // 5. 构建结果 + result := &ExecutionResult{ + TaskID: req.TaskID, + LogID: req.LogID, // 传递 LogID + } + + // 统一获取输出并调用封装的脱敏函数 + rawStr := utils.MaskSecrets(combinedBuf.String(), req.Secrets) + + if execResult != nil { + result.Success = execResult.Status == constant.TaskStatusSuccess + result.Output = rawStr + result.Status = execResult.Status + result.Duration = execResult.Duration + result.ExitCode = execResult.ExitCode + result.StartTime = execResult.StartTime + result.EndTime = execResult.EndTime + } else { + result.Success = false + result.Status = constant.TaskStatusFailed + result.StartTime = start + result.EndTime = time.Now() + result.Duration = result.EndTime.Sub(result.StartTime).Milliseconds() + result.Output = rawStr + } + + if execErr != nil { + result.Error = execErr.Error() + if ctx.Err() == context.Canceled { + result.Status = constant.TaskStatusCancelled + } else if ctx.Err() == context.DeadlineExceeded { + result.Status = constant.TaskStatusTimeout + } + } + + // 6. 执行后事件 + if s.handler != nil { + if execResult != nil { + // 只要有执行结果(即使执行失败),都认为是任务完成了(包含输出) + s.handler.OnTaskCompleted(req, result) + } else if execErr != nil { + // 只有在完全没有结果的情况下(如无法启动、Panic等),才认为是任务失败 + s.handler.OnTaskFailed(req, execErr) + } + } + + if execErr != nil { + s.logger.Errorf("[Scheduler] 任务 %s 执行失败: %v", req.TaskID, execErr) + } else { + s.logger.Infof("[Scheduler] 执行完成: %s (#%s) [%s] (状态: %s, 耗时: %dms)", + req.Name, req.TaskID, req.Type, result.Status, result.Duration) + } + + return result, execErr +} + +// StopTask 停止正在运行的任务(通过 TaskID,可能会停止多个并发副本) +func (s *Scheduler) StopTask(taskID string) bool { + s.mu.RLock() + cancel, exists := s.runningTasks[taskID] + s.mu.RUnlock() + + if exists && cancel != nil { + cancel() + s.logger.Infof("[Scheduler] 已尝试停止任务 %s", taskID) + return true + } + return false +} + +// StopLog 停止正在运行的任务(通过 LogID,精确停止单个执行副本) +func (s *Scheduler) StopLog(logID string) bool { + s.mu.RLock() + cancel, exists := s.runningExecs[logID] + s.mu.RUnlock() + + if exists && cancel != nil { + cancel() + s.logger.Infof("[Scheduler] 已尝试停止任务执行 #%s", logID) + return true + } + return false +} + +// GetRunningTaskCount 获取正在运行的任务数量 +func (s *Scheduler) GetRunningTaskCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.runningTasks) +} + +// GetRunningTasks 获取所有正在运行的任务 ID +func (s *Scheduler) GetRunningTasks() []string { + s.mu.RLock() + defer s.mu.RUnlock() + ids := make([]string, 0, len(s.runningTasks)) + for id := range s.runningTasks { + ids = append(ids, id) + } + return ids +} + +// Reload 重新加载配置 +func (s *Scheduler) Reload(config SchedulerConfig) { + s.logger.Infof("[Scheduler] 正在重载配置...") + + // 停止现有 workers + close(s.stopCh) + s.wg.Wait() + + // 更新配置 + s.mu.Lock() + s.config = config + s.taskQueue = make(chan *ExecutionRequest, config.QueueSize) + s.rateLimiter = time.Tick(config.RateInterval) + s.stopCh = make(chan struct{}) + s.mu.Unlock() + + // 重启 workers + s.Start() + + s.logger.Infof("[Scheduler] 配置已重载: workers=%d, queue=%d, rate=%v, strict=%t", + config.WorkerCount, config.QueueSize, config.RateInterval, config.StrictQueue) +} + +// GetQueueSize 获取当前队列大小 +func (s *Scheduler) GetQueueSize() int { + return len(s.taskQueue) +} + +// GetConfig 获取配置 +func (s *Scheduler) GetConfig() SchedulerConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.config +} + +// GetWorkerStatuses 获取所有 Worker 的状态 +func (s *Scheduler) GetWorkerStatuses() []WorkerStatus { + s.workerMu.RLock() + defer s.workerMu.RUnlock() + // 返回副本防止外部修改 + statuses := make([]WorkerStatus, len(s.workers)) + now := time.Now().Unix() + for i, w := range s.workers { + statuses[i] = w + // 在服务端计算运行时间,彻底避免客户端与服务端时钟不一致导致的计算偏差 + if w.Status == "running" && w.StartTime > 0 { + duration := now - w.StartTime + if duration < 0 { + duration = 0 + } + statuses[i].Duration = duration + } + } + return statuses +} diff --git a/internal/executor/syscron.go b/internal/executor/syscron.go new file mode 100644 index 0000000..ea8cf69 --- /dev/null +++ b/internal/executor/syscron.go @@ -0,0 +1,69 @@ +package executor + +import ( + "sync" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/systime" + "github.com/robfig/cron/v3" +) + +type SysCronManager struct { + cron *cron.Cron +} + +var ( + sysCronInstance *SysCronManager + sysCronOnce sync.Once +) + + +// InitSysCron 初始化系统的内部定时器 +func InitSysCron(){ + GetSysCron() +} + +// GetSysCron 获取内部系统定时器服务单例 +func GetSysCron() *SysCronManager { + sysCronOnce.Do(func() { + // 使用秒级精度,指定为东八区 + c := cron.New(cron.WithSeconds(), cron.WithLocation(systime.CST)) + c.Start() + sysCronInstance = &SysCronManager{ + cron: c, + } + logger.Infof("[SysCron] 内部系统定时管理器已启动") + }) + return sysCronInstance +} + +// AddJob 添加内部系统任务,spec为cron表达式(支持 @every 30s 这种快捷方式) +func (s *SysCronManager) AddJob(spec string, cmd func()) (cron.EntryID, error) { + id, err := s.cron.AddFunc(spec, cmd) + if err != nil { + logger.Errorf("[SysCron] 无法添加系统任务: %s, err: %v", spec, err) + return 0, err + } + return id, nil +} + +// AddJobWithRun 立即开启一个协程异步执行一次任务,随后将其加入到系统定时任务中 +func (s *SysCronManager) AddJobWithRun(spec string, cmd func()) (cron.EntryID, error) { + // 立即异步执行一次 + go func() { + defer func() { + if r := recover(); r != nil { + logger.Errorf("[SysCron] 立即执行任务时发生 panic: %v", r) + } + }() + cmd() + }() + + // 然后加入定时器 + return s.AddJob(spec, cmd) +} + +// RemoveJob 动态移除指定的系统定时任务 +func (s *SysCronManager) RemoveJob(id cron.EntryID) { + s.cron.Remove(id) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..47777a2 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,181 @@ +package logger + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/engigu/taskpool/internal/systime" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var Log *zap.Logger +var Sugar *zap.SugaredLogger +var atomicLevel zap.AtomicLevel + +// ANSI 颜色代码 +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" // error, fatal, panic + colorYellow = "\033[33m" // warn + colorBlue = "\033[36m" // info + colorGray = "\033[37m" // debug +) + +// customCore 实现 zapcore.Core 以提供与 logrus 一模一样的格式 +type customCore struct { + level zapcore.LevelEnabler + writer zapcore.WriteSyncer +} + +func (c *customCore) Enabled(l zapcore.Level) bool { + return c.level.Enabled(l) +} + +func (c *customCore) With(fields []zapcore.Field) zapcore.Core { + // 目前忽略字段,以保持与旧 logrus 格式一模一样(旧格式只输出 entry.Message) + return c +} + +func (c *customCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(ent.Level) { + return ce.AddCore(ent, c) + } + return ce +} + +func (c *customCore) Write(ent zapcore.Entry, fields []zapcore.Field) error { + // 统一使用东八区时间 + timestamp := systime.InCST(ent.Time).Format("2006-01-02 15:04:05") + level := strings.ToUpper(ent.Level.String()) + + var levelColor string + switch ent.Level { + case zapcore.DebugLevel: + levelColor = colorGray + case zapcore.InfoLevel: + levelColor = colorBlue + case zapcore.WarnLevel: + levelColor = colorYellow + case zapcore.ErrorLevel, zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel: + levelColor = colorRed + default: + levelColor = colorBlue + } + + msg := fmt.Sprintf("[%s]%s[%s]%s %s\n", timestamp, levelColor, level, colorReset, ent.Message) + _, err := c.writer.Write([]byte(msg)) + return err +} + +func (c *customCore) Sync() error { + return c.writer.Sync() +} + +func newLogger(output zapcore.WriteSyncer) *zap.Logger { + core := &customCore{ + level: atomicLevel, + writer: output, + } + return zap.New(core) +} + +func init() { + // 强制设置全局时区为东八区 + time.Local = systime.CST + atomicLevel = zap.NewAtomicLevelAt(zap.InfoLevel) + Log = newLogger(zapcore.AddSync(os.Stdout)) + Sugar = Log.Sugar() +} + +// SetupFileOutput 设置文件输出 +func SetupFileOutput(logDir string) error { + if err := os.MkdirAll(logDir, 0755); err != nil { + return err + } + + logFile := filepath.Join(logDir, systime.FormatDate(time.Now())+".log") + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return err + } + + Log = newLogger(zapcore.AddSync(file)) + Sugar = Log.Sugar() + return nil +} + +// SetOutput 直接设置 Log 实例 +func SetOutput(l *zap.Logger) { + Log = l + Sugar = l.Sugar() +} + +// SetSugar 直接设置 Sugar 实例 +func SetSugar(s *zap.SugaredLogger) { + Sugar = s +} + +// SetLevel 设置日志级别 +func SetLevel(level string) { + switch level { + case "debug": + atomicLevel.SetLevel(zap.DebugLevel) + case "info": + atomicLevel.SetLevel(zap.InfoLevel) + case "warn": + atomicLevel.SetLevel(zap.WarnLevel) + case "error": + atomicLevel.SetLevel(zap.ErrorLevel) + default: + atomicLevel.SetLevel(zap.InfoLevel) + } +} + +// 便捷方法 +func Debug(args ...interface{}) { Sugar.Debug(args...) } +func Info(args ...interface{}) { Sugar.Info(args...) } +func Warn(args ...interface{}) { Sugar.Warn(args...) } +func Error(args ...interface{}) { Sugar.Error(args...) } +func Fatal(args ...interface{}) { Sugar.Fatal(args...) } + +func Debugf(format string, args ...interface{}) { Sugar.Debugf(format, args...) } +func Infof(format string, args ...interface{}) { Sugar.Infof(format, args...) } +func Warnf(format string, args ...interface{}) { Sugar.Warnf(format, args...) } +func Errorf(format string, args ...interface{}) { Sugar.Errorf(format, args...) } +func Fatalf(format string, args ...interface{}) { Sugar.Fatalf(format, args...) } + +// WithField 带字段的日志 +func WithField(key string, value interface{}) *zap.SugaredLogger { + return Sugar.With(key, value) +} + +// WithFields 带多个字段的日志 +func WithFields(fields map[string]interface{}) *zap.SugaredLogger { + f := make([]interface{}, 0, len(fields)*2) + for k, v := range fields { + f = append(f, k, v) + } + return Sugar.With(f...) +} + +// SchedulerLogger 兼容 internal/executor 的日志接口 +type SchedulerLogger struct{} + +func (s *SchedulerLogger) Infof(format string, args ...interface{}) { + Sugar.Infof(format, args...) +} +func (s *SchedulerLogger) Warnf(format string, args ...interface{}) { + Sugar.Warnf(format, args...) +} +func (s *SchedulerLogger) Errorf(format string, args ...interface{}) { + Sugar.Errorf(format, args...) +} + +// NewSchedulerLogger 创建一个兼容 executor.SchedulerLogger 的实例 +func NewSchedulerLogger() *SchedulerLogger { + return &SchedulerLogger{} +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..6d313a8 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,329 @@ +package middleware + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + + "github.com/gin-gonic/gin" +) + +// AuthRequired 认证中间件 +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + // 基础的 CSRF 防护:校验 Origin/Referer (针对非 GET 请求) + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodOptions && c.Request.Method != http.MethodHead { + origin := c.GetHeader("Origin") + if origin == "" { + origin = c.GetHeader("Referer") + } + // 如果有 Origin 且不匹配则拒绝(实际部署时应配置允许的 Origin) + if origin != "" && !utils.CheckWSOrigin(c.Request) { + utils.Forbidden(c, "CSRF 校验失败: 非法的请求来源") + c.Abort() + return + } + } + + // 检查是否携带互联 Token(支持跨面板远程全接口调用) + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if tokenStr != "" { + settingsSvc := services.NewSettingsService() + interconnectToken := settingsSvc.Get(constant.SectionSite, constant.KeyInterconnectToken) + parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken) + + isMatched := false + h1 := sha256.Sum256([]byte(tokenStr)) + + if interconnectToken != "" { + h2 := sha256.Sum256([]byte(interconnectToken)) + if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 { + isMatched = true + } + } + + if !isMatched && parentToken != "" { + h2 := sha256.Sum256([]byte(parentToken)) + if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 { + isMatched = true + } + } + + if isMatched { + // 模拟 Admin 角色 + var adminUser models.User + res := database.DB.Where("role = ?", constant.AdminRole).Limit(1).Find(&adminUser) + if res.Error == nil && res.RowsAffected > 0 { + c.Set("userID", adminUser.ID) + c.Set("username", adminUser.Username) + c.Set("role", adminUser.Role) + c.Next() + return + } + } + } + } + + token, err := c.Cookie(constant.CookieName) + if err != nil || token == "" { + utils.Unauthorized(c, "请先登录") + c.Abort() + return + } + + // 验证 token + userID, username, tokenVersion, err := utils.ParseToken(token, constant.Secret) + if err != nil { + utils.Unauthorized(c, "登录已过期,请重新登录") + c.Abort() + return + } + + // 安全增强:校验数据库中该用户的 ID 是否与 Token 一致,并验证 TokenVersion + var user models.User + res := database.DB.Where("username = ?", username).Limit(1).Find(&user) + if res.Error != nil || res.RowsAffected == 0 || user.ID != userID || user.TokenVersion != tokenVersion { + utils.Unauthorized(c, "会话失效,请重新登录") + ClearAuthCookie(c) + c.Abort() + return + } + + // 将用户信息存入上下文 (必须使用数据库中的最新 ID) + c.Set("userID", user.ID) + c.Set("username", user.Username) + c.Set("role", user.Role) + c.Next() + } +} + +// AdminRequired 管理员权限认证中间件 +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists || role != constant.AdminRole { + utils.Forbidden(c, "需要管理员权限") + c.Abort() + return + } + c.Next() + } +} + +// OpenapiRequired OpenAPI 认证中间件 +func OpenapiRequired() gin.HandlerFunc { + settingsSvc := services.NewSettingsService() + return func(c *gin.Context) { + if checkOpenapiToken(c, settingsSvc) { + return + } + utils.Unauthorized(c, "无效的 OpenAPI 令牌") + c.Abort() + } +} + +// checkOpenapiToken 校验 OpenAPI Token +// 返回 true 表示校验通过并已放行请求 +func checkOpenapiToken(c *gin.Context, settingsSvc *services.SettingsService) bool { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + return false + } + + // 提取 token:支持 "Bearer " 和直接 "" 两种格式 + var openapiToken string + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + // 标准格式:Bearer + openapiToken = authHeader[7:] + } else { + // 直接使用 token + openapiToken = authHeader + } + + // Token 不能为空 + if openapiToken == "" { + return false + } + + siteConfig := settingsSvc.GetSection(constant.SectionSite) + tokenJson, ok := siteConfig[constant.KeyOpenapiToken] + if !ok || tokenJson == "" { + return false + } + + var tokenConfig vo.TokenConfig + if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil { + return false + } + + // 校验开启状态 + if !tokenConfig.Enabled { + return false + } + + if tokenConfig.Token == "" { + return false + } + + // 使用恒定时间比较防止时序攻击 + h1 := sha256.Sum256([]byte(openapiToken)) + h2 := sha256.Sum256([]byte(tokenConfig.Token)) + if subtle.ConstantTimeCompare(h1[:], h2[:]) != 1 { + return false + } + + // 检查过期时间 + if tokenConfig.ExpireAt != "" { + expireDate, err := time.Parse("2006-01-02", tokenConfig.ExpireAt) + if err == nil { + expireDate = expireDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + if time.Now().After(expireDate) { + return false + } + } + } + + // 模拟 Admin 角色 + var adminUser models.User + res := database.DB.Where("role = ?", "admin").Limit(1).Find(&adminUser) + if res.Error != nil || res.RowsAffected == 0 { + utils.Unauthorized(c, "未找到管理员账户,OpenAPI Token 校验失败") + c.Abort() + return true + } + + c.Set("userID", adminUser.ID) + c.Set("username", adminUser.Username) + c.Set("role", adminUser.Role) + c.Next() + return true +} + +// SetAuthCookie 设置认证 Cookie,expireDays 为过期天数 +func SetAuthCookie(c *gin.Context, token string, expireDays int) { + maxAge := 86400 * expireDays + // 增加 SameSite=Lax 和 Secure 属性(如果环境支持,这里暂时设为 false,但生产建议 true) + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(constant.CookieName, token, maxAge, "/", "", false, true) +} + +// ClearAuthCookie 清除认证 Cookie +func ClearAuthCookie(c *gin.Context) { + c.SetCookie(constant.CookieName, "", -1, "/", "", false, true) +} + +// SwaggerAuth Swagger 认证中间件 (Basic Auth) +func SwaggerAuth() gin.HandlerFunc { + return func(c *gin.Context) { + settingsSvc := services.NewSettingsService() + siteConfig := settingsSvc.GetSection(constant.SectionSite) + tokenJson := siteConfig[constant.KeyOpenapiToken] + + if tokenJson == "" { + c.Status(http.StatusNotFound) + c.Abort() + return + } + + var tokenConfig vo.TokenConfig + if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil { + c.Status(http.StatusNotFound) + c.Abort() + return + } + + // 必须开启鉴权开关 + if !tokenConfig.Enabled { + c.Status(http.StatusNotFound) + c.Abort() + return + } + + // 检查过期时间 + if tokenConfig.ExpireAt != "" { + expire, err := time.ParseInLocation("2006/01/02", tokenConfig.ExpireAt, time.Local) + if err == nil { + // 包含当天,所以设置到当天 23:59:59 + expire = expire.Add(24*time.Hour - time.Second) + if time.Now().After(expire) { + c.Status(http.StatusNotFound) + c.Abort() + return + } + } + } + + // 获取请求中携带的凭证 + // 1. URL 参数 token + // 2. Cookie 中的 openapi_token + // 3. HTTP Basic Auth + tokenQuery := c.Query("token") + tokenCookie, _ := c.Cookie("openapi_token") + _, password, hasAuth := c.Request.BasicAuth() + + var providedToken string + if tokenQuery != "" { + providedToken = tokenQuery + } else if tokenCookie != "" { + providedToken = tokenCookie + } else if hasAuth { + providedToken = password + } + + // 检查提供的 token 是否匹配 + if providedToken != "" { + h1 := sha256.Sum256([]byte(providedToken)) + h2 := sha256.Sum256([]byte(tokenConfig.Token)) + if subtle.ConstantTimeCompare(h1[:], h2[:]) == 1 { + // 如果是通过 url 参数进来的,自动将其种入 Cookie,便于后续加载静态资源 (如 json) + if tokenQuery != "" { + c.SetCookie("openapi_token", providedToken, 86400, "/openapi", "", false, false) + } + c.Next() + return + } + } + + // 验证失败,不再返回 WWW-Authenticate 头触发浏览器反人类原生弹窗 + // 我们返回 401 的 JSON 或纯文本结构,以便由调用方自行接管鉴权逻辑 + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 401, + "msg": "OpenAPI 访问未授权或 Token 错误", + }) + c.Abort() + } +} + +// LocalhostOnly 仅允许本地回环地址访问,并进行简单的内部凭证校验 +func LocalhostOnly() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + if ip != "127.0.0.1" && ip != "::1" { + utils.BadRequest(c, "仅允许本地访问") + c.Abort() + return + } + + // 简单的内部通信认证 + token := c.GetHeader("X-Internal-Token") + if token == "" || token != constant.Secret { + utils.Unauthorized(c, "无效的内部调用凭证") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..1659844 --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "fmt" + "time" + + "github.com/engigu/taskpool/internal/logger" + + "github.com/gin-gonic/gin" +) + +// GinLogger 返回使用 logrus 的 Gin 日志中间件 +func GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + clientIP := c.ClientIP() + method := c.Request.Method + + if query != "" { + path = path + "?" + query + } + + // 格式化延迟时间 + var latencyStr string + if latency < time.Millisecond { + latencyStr = fmt.Sprintf("%dµs", latency.Microseconds()) + } else if latency < time.Second { + latencyStr = fmt.Sprintf("%dms", latency.Milliseconds()) + } else { + latencyStr = fmt.Sprintf("%.2fs", latency.Seconds()) + } + + msg := fmt.Sprintf("[HTTP] %d %s %s %s [%s]", status, method, path, latencyStr, clientIP) + + if status >= 500 { + logger.Error(msg) + } else if status >= 400 { + logger.Warn(msg) + } else { + logger.Info(msg) + } + } +} + +// GinRecovery 返回使用 logrus 的 Gin 恢复中间件 +func GinRecovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + logger.Errorf("[HTTP] Panic: %v | %s", err, c.Request.URL.Path) + c.AbortWithStatus(500) + } + }() + c.Next() + } +} diff --git a/internal/middleware/notify_token.go b/internal/middleware/notify_token.go new file mode 100644 index 0000000..559b2ee --- /dev/null +++ b/internal/middleware/notify_token.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/utils" + "github.com/gin-gonic/gin" +) + +// NotifyTokenAuth 通知 Token 认证中间件 +func NotifyTokenAuth() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("notify-token") + if token == "" { + utils.Unauthorized(c, "缺少通知 Token") + c.Abort() + return + } + + // 从 settings 表读取配置的通知 Token + settingsService := services.NewSettingsService() + savedToken := settingsService.Get(constant.SectionNotify, constant.KeyNotifyToken) + + if savedToken == "" { + utils.Unauthorized(c, "通知 Token 未配置") + c.Abort() + return + } + + if !strings.EqualFold(token, savedToken) { + utils.Unauthorized(c, "通知 Token 无效") + c.Abort() + return + } + + c.Next() + } +} diff --git a/internal/middleware/travel_proxy.go b/internal/middleware/travel_proxy.go new file mode 100644 index 0000000..d2662a0 --- /dev/null +++ b/internal/middleware/travel_proxy.go @@ -0,0 +1,133 @@ +package middleware + +import ( + "context" + "io" + "net/http" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/tunnel" + "github.com/gin-gonic/gin" +) + +var travelProxyClient = &http.Client{ + Timeout: 10 * time.Second, +} + +func TravelProxyMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 1. 检查是否存在 active_interconnect_node_id Cookie + nodeID, err := c.Cookie(constant.CookieActiveInterconnectNodeID) + if err != nil || nodeID == "" { + c.Next() + return + } + + // 2. 白名单放行:反向隧道建立连接端点必须直达本机,不能二次代理 + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api/v1/interconnect/tunnel") { + c.Next() + return + } + + // 3. 查询数据库中节点信息 + var node models.InterconnectNode + if err := database.DB.Where("id = ?", nodeID).First(&node).Error; err != nil { + // 节点不存在,说明 Cookie 无效,清除并放行 + c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false) + c.Next() + return + } + + // 4. 准备代理的路径(若主节点配置了 URLPrefix,需要剥离) + cfg := services.GetConfig() + urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/") + targetPath := path + if urlPrefix != "" { + targetPath = strings.TrimPrefix(targetPath, urlPrefix) + } + if !strings.HasPrefix(targetPath, "/") { + targetPath = "/" + targetPath + } + + // 5. 执行代理转发 + if strings.HasPrefix(node.URL, "tunnel://") { + // 走 WebSocket 逆向 Yamux 隧道 + err := tunnel.ProxyHTTP(nodeID, c, targetPath) + if err != nil { + // 如果是网页 HTML 导航请求,提供友好降级返回主节点 + if strings.Contains(c.GetHeader("Accept"), "text/html") { + c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false) + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(200, `

与子节点连接失败,正在自动返回主节点...

`) + c.Abort() + return + } + c.JSON(502, gin.H{"code": 502, "msg": "与子节点逆向隧道通信异常: " + err.Error()}) + c.Abort() + return + } + c.Abort() + return + } + + // 走普通 HTTP 直连代理 + targetURL := strings.TrimRight(node.URL, "/") + targetPath + if c.Request.URL.RawQuery != "" { + targetURL += "?" + c.Request.URL.RawQuery + } + + req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body) + if err != nil { + c.JSON(500, gin.H{"code": 500, "msg": "Failed to create proxy request"}) + c.Abort() + return + } + + // 复制请求头 + req.Header = c.Request.Header.Clone() + + // 覆盖认证授权 Header 确保子节点鉴权通过 + if node.Token != "" { + req.Header.Set("Authorization", "Bearer "+node.Token) + } + // 移除 Cookie 头部防干扰 + req.Header.Del("Cookie") + req.Header.Set("X-Tunnel-Proxy", "true") + + resp, err := travelProxyClient.Do(req) + if err != nil { + // 如果是客户端自己主动取消了请求(例如连续刷新、关闭网页等),直接退出,不应视为子节点离线而执行退回主节点的操作 + if c.Request.Context().Err() == context.Canceled || strings.Contains(err.Error(), "context canceled") { + c.Abort() + return + } + if strings.Contains(c.GetHeader("Accept"), "text/html") { + c.SetCookie(constant.CookieActiveInterconnectNodeID, "", -1, "/", "", false, false) + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(200, `

与子节点连接失败,正在自动返回主节点...

`) + c.Abort() + return + } + c.JSON(502, gin.H{"code": 502, "msg": "无法连接至目标子节点: " + err.Error()}) + c.Abort() + return + } + defer resp.Body.Close() + + // 复制响应头 + for k, v := range resp.Header { + for _, vv := range v { + c.Writer.Header().Add(k, vv) + } + } + c.Status(resp.StatusCode) + io.Copy(c.Writer, resp.Body) + c.Abort() + } +} diff --git a/internal/models/agent.go b/internal/models/agent.go new file mode 100644 index 0000000..93fb35d --- /dev/null +++ b/internal/models/agent.go @@ -0,0 +1,161 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" + + "github.com/engigu/taskpool/internal/constant" +) + +// AgentSchedulerConfig Agent 调度器配置 +type AgentSchedulerConfig struct { + WorkerCount int `json:"worker_count"` + QueueSize int `json:"queue_size"` + RateInterval time.Duration `json:"rate_interval"` + Verbose bool `json:"verbose"` + StrictQueue bool `json:"strict_queue"` +} + +// Value 序列化为数据库字符串 +func (c AgentSchedulerConfig) Value() (driver.Value, error) { + bytes, err := json.Marshal(c) + if err != nil { + return nil, err + } + return string(bytes), nil +} + +// Scan 反序列化数据库字符串为结构体 +func (c *AgentSchedulerConfig) Scan(value interface{}) error { + if value == nil { + return nil + } + bytes, ok := value.([]byte) + if !ok { + str, ok := value.(string) + if !ok { + return errors.New("invalid type for AgentSchedulerConfig") + } + bytes = []byte(str) + } + return json.Unmarshal(bytes, c) +} + +// Agent 远程执行代理 +type Agent struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:100;not null"` // Agent 名称 + Token string `json:"token" gorm:"size:64;index"` // 认证 Token(可重复使用) + MachineID string `json:"machine_id" gorm:"size:64;uniqueIndex"` // 机器识别码(唯一) + Description string `json:"description" gorm:"size:255"` // 描述 + Status string `json:"status" gorm:"size:20;default:'pending';index"` // 状态: constant.AgentStatusOnline, constant.AgentStatusOffline + LastSeen *LocalTime `json:"last_seen"` // 最后心跳时间 + IP string `json:"ip" gorm:"size:45"` // Agent IP 地址 + Version string `json:"version" gorm:"size:50"` // Agent 版本 + BuildTime string `json:"build_time" gorm:"size:30"` // Agent 构建时间 + Hostname string `json:"hostname" gorm:"size:100"` // Agent 主机名 + OS string `json:"os" gorm:"size:20"` // 操作系统 + Arch string `json:"arch" gorm:"size:20"` // 架构 + ForceUpdate bool `json:"force_update" gorm:"default:false"` // 强制更新标志 + Enabled *bool `json:"enabled" gorm:"default:true"` // 是否启用 + SchedulerConfig AgentSchedulerConfig `json:"scheduler_config" gorm:"type:text"` // 调度配置,以 JSON 字符串形式存储在 Text 类型字段中 + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (Agent) TableName() string { + return constant.TablePrefix + "agents" +} + +// AgentToken Agent 令牌 +type AgentToken struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Token string `json:"token" gorm:"size:64;uniqueIndex;not null"` // 令牌 + Remark string `json:"remark" gorm:"size:255"` // 备注 + MaxUses int `json:"max_uses" gorm:"default:0"` // 最大使用次数,0 表示无限制 + UsedCount int `json:"used_count" gorm:"default:0"` // 已使用次数 + ExpiresAt *LocalTime `json:"expires_at"` // 过期时间,null 表示永不过期 + Enabled *bool `json:"enabled" gorm:"default:true"` // 是否启用 + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (AgentToken) TableName() string { + return constant.TablePrefix + "tokens" +} + +// AgentTask Agent 任务配置(用于下发给 Agent) +type AgentTask struct { + ID string `json:"id"` + Name string `json:"name"` + Command string `json:"command"` + PreCommand string `json:"pre_command"` + PostCommand string `json:"post_command"` + Schedule string `json:"schedule"` + Timeout int `json:"timeout"` + WorkDir string `json:"work_dir"` + Envs string `json:"envs"` + Languages []map[string]string `json:"languages"` + RandomRange int `json:"random_range"` + Secrets []string `json:"secrets"` + Enabled bool `json:"enabled"` +} + +func (t AgentTask) GetID() string { + return t.ID +} + +func (t AgentTask) GetName() string { + return t.Name +} + +func (t AgentTask) GetCommand() string { + return t.Command +} + +func (t AgentTask) GetPreCommand() string { + return t.PreCommand +} + +func (t AgentTask) GetPostCommand() string { + return t.PostCommand +} + +func (t AgentTask) GetSchedule() string { + return t.Schedule +} + +func (t AgentTask) GetRandomRange() int { + return t.RandomRange +} + +func (t AgentTask) GetSecrets() []string { + return t.Secrets +} + +// AgentTaskResult Agent 上报的任务执行结果 +type AgentTaskResult struct { + TaskID string `json:"task_id"` + LogID string `json:"log_id"` + AgentID string `json:"agent_id"` + Command string `json:"command"` + Output string `json:"output"` + Error string `json:"error"` // 额外的系统错误信息 + Status string `json:"status"` // success, failed + Duration int64 `json:"duration"` // 耗时(毫秒) + ExitCode int `json:"exit_code"` + StartTime int64 `json:"start_time"` // Unix 时间戳 + EndTime int64 `json:"end_time"` // Unix 时间戳 +} + +// AgentRegisterRequest Agent 注册请求 +type AgentRegisterRequest struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + Version string `json:"version"` + BuildTime string `json:"build_time"` + Token string `json:"token"` // 注册令牌 + MachineID string `json:"machine_id"` // 机器识别码 +} diff --git a/internal/models/app_log.go b/internal/models/app_log.go new file mode 100644 index 0000000..79a93eb --- /dev/null +++ b/internal/models/app_log.go @@ -0,0 +1,24 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// AppLog 统一应用日志与通知记录 +type AppLog struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Category string `json:"category" gorm:"size:50;index;not null"` // 大类:constant.LogCategorySystemNotice(系统通知), constant.LogCategoryPushLog(推送记录) 等 + Title string `json:"title" gorm:"size:255"` // 消息标题 + Content BigText `json:"content"` // 详细内容/Payload + Level string `json:"level" gorm:"size:20;index"` // 级别:constant.LogLevelInfo, constant.LogLevelWarning, constant.LogLevelError + Status string `json:"status" gorm:"size:20;index"` // 状态:系统通知为 constant.LogStatusRead/constant.LogStatusUnread,推送为 constant.LogStatusSuccess/constant.LogStatusFailed + RefID string `json:"ref_id" gorm:"size:50;index"` // 关联对象ID(选填,比如绑定的通知渠道ID、任务ID等) + ErrorMsg BigText `json:"error_msg"` // 执行错误信息详情 + CreatedAt LocalTime `json:"created_at" gorm:"index"` + ReadAt *LocalTime `json:"read_at"` // 已读时间(仅对通知生效) + ChannelName string `json:"channel_name" gorm:"-"` // 推送记录的关联渠道名称(动态查询) +} + +func (AppLog) TableName() string { + return constant.TablePrefix + "app_logs" +} diff --git a/internal/models/big_text.go b/internal/models/big_text.go new file mode 100644 index 0000000..faac288 --- /dev/null +++ b/internal/models/big_text.go @@ -0,0 +1,24 @@ +package models + +import ( + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// BigText 自定义大数据文本类型,自动处理跨数据库类型差异 +// MySQL: LONGTEXT (4GB) +// PostgreSQL: TEXT +// SQLite: TEXT +type BigText string + +func (BigText) GormDBDataType(db *gorm.DB, field *schema.Field) string { + switch db.Dialector.Name() { + case "mysql": + return "LONGTEXT" + case "postgres": + return "TEXT" + case "sqlite": + return "TEXT" + } + return "TEXT" +} diff --git a/internal/models/data_rel.go b/internal/models/data_rel.go new file mode 100644 index 0000000..e867ecd --- /dev/null +++ b/internal/models/data_rel.go @@ -0,0 +1,33 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// DataRelation 通用数据关联表 +type DataRelation struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + DataID string `json:"data_id" gorm:"size:20;index;not null"` + RelateID string `json:"relate_id" gorm:"size:20;index;not null"` + Type string `json:"type" gorm:"size:50;index;not null"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (DataRelation) TableName() string { + return constant.TablePrefix + "data_relations" +} + +// DataStorage 通用数据存储表 +type DataStorage struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Type string `json:"type" gorm:"size:50;index;not null"` + Name string `json:"name" gorm:"size:255;index;not null"` + Data BigText `json:"data"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (DataStorage) TableName() string { + return constant.TablePrefix + "data_storages" +} diff --git a/internal/models/dependency.go b/internal/models/dependency.go new file mode 100644 index 0000000..15a1f54 --- /dev/null +++ b/internal/models/dependency.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "github.com/engigu/taskpool/internal/constant" +) + +// Dependency 依赖包模型 +type Dependency struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:100;not null"` + Version string `json:"version" gorm:"size:50"` + Language string `json:"language" gorm:"size:100;index"` // 关联语言 (node, python...) + LangVersion string `json:"lang_version" gorm:"size:100;index"` // 关联语言版本 + Remark string `json:"remark" gorm:"size:255"` + Log BigText `json:"log"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Dependency) TableName() string { + return constant.TablePrefix + "deps" +} diff --git a/internal/models/env.go b/internal/models/env.go new file mode 100644 index 0000000..e798e30 --- /dev/null +++ b/internal/models/env.go @@ -0,0 +1,38 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// EnvironmentVariable represents an environment variable +type EnvironmentVariable struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:255;not null"` + Value BigText `json:"value"` + Remark string `json:"remark" gorm:"size:500"` + Type string `json:"type" gorm:"size:20;default:'normal'"` + Hidden *bool `json:"hidden" gorm:"default:true"` + Enabled *bool `json:"enabled" gorm:"default:true"` + UserID string `json:"user_id" gorm:"size:20;index"` + Tags string `json:"-" gorm:"-"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (EnvironmentVariable) TableName() string { + return constant.TablePrefix + "envs" +} + +// Script represents a script file +type Script struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:255;not null"` + Content BigText `json:"content"` + UserID string `json:"user_id" gorm:"size:20;index"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (Script) TableName() string { + return constant.TablePrefix + "scripts" +} diff --git a/internal/models/export.go b/internal/models/export.go new file mode 100644 index 0000000..6376b8c --- /dev/null +++ b/internal/models/export.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// ExportData 全量或部分业务数据的导出/导入结构 +type ExportData struct { + Version string `json:"version"` + ExportAt LocalTime `json:"export_at"` + Tasks []Task `json:"tasks"` + Envs []EnvironmentVariable `json:"envs"` + Tags []DataStorage `json:"tags"` + Bindings []NotifyBinding `json:"bindings"` +} + +// NewExportData 创建一个导出数据对象 +func NewExportData() *ExportData { + return &ExportData{ + Version: "1.0", + ExportAt: LocalTime(time.Now()), + Tasks: make([]Task, 0), + Envs: make([]EnvironmentVariable, 0), + Tags: make([]DataStorage, 0), + Bindings: make([]NotifyBinding, 0), + } +} diff --git a/internal/models/interconnect.go b/internal/models/interconnect.go new file mode 100644 index 0000000..8a39778 --- /dev/null +++ b/internal/models/interconnect.go @@ -0,0 +1,32 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// NodeMetrics 表示互联节点的性能指标,使用 JSON 存储 +type NodeMetrics struct { + CPUPercent float64 `json:"cpu_percent"` + MemPercent float64 `json:"mem_percent"` + DiskPercent float64 `json:"disk_percent"` + TxBytes uint64 `json:"tx_bytes,omitempty"` + RxBytes uint64 `json:"rx_bytes,omitempty"` +} + +// InterconnectNode represents a connected remote panel +type InterconnectNode struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:255;not null"` + URL string `json:"url" gorm:"size:255;not null"` + Token string `json:"token" gorm:"size:255"` + Remark string `json:"remark" gorm:"size:500"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` + Status string `json:"status" gorm:"size:50"` // online / offline + Metrics NodeMetrics `json:"metrics" gorm:"serializer:json"` + LastHeartbeatAt *LocalTime `json:"last_heartbeat_at"` +} + +func (InterconnectNode) TableName() string { + return constant.TablePrefix + "interconnect_nodes" +} diff --git a/internal/models/language.go b/internal/models/language.go new file mode 100644 index 0000000..1e86b03 --- /dev/null +++ b/internal/models/language.go @@ -0,0 +1,20 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +type Language struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Plugin string `json:"plugin" gorm:"size:100;not null;index"` + Version string `json:"version" gorm:"size:100;not null;index"` + InstallPath string `json:"install_path" gorm:"size:255"` + Source string `json:"source" gorm:"size:255"` + InstalledAt *LocalTime `json:"installed_at"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (Language) TableName() string { + return constant.TablePrefix + "languages" +} diff --git a/internal/models/notify_binding.go b/internal/models/notify_binding.go new file mode 100644 index 0000000..4e6db64 --- /dev/null +++ b/internal/models/notify_binding.go @@ -0,0 +1,27 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// NotifyBinding 事件绑定表 +type NotifyBinding struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Type string `json:"type" gorm:"size:20;not null;index"` // system 或 task + Event string `json:"event" gorm:"size:50;not null;index"` // 事件类型 + WayID string `json:"way_id" gorm:"size:20;not null;index"` // 通知渠道ID + DataID string `json:"data_id" gorm:"size:20;index"` // 关联ID,系统事件为空,任务事件为任务ID + Extra BigText `json:"extra"` // 额外配置(如是否开启日志推送等,对应 BindingExtra 结构) + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +// BindingExtra 存储在 Extra 字段中的 JSON 配置 +type BindingExtra struct { + EnableLog bool `json:"enable_log"` + LogLimit int `json:"log_limit"` // 日志字数限制,默认 1000 +} + +func (NotifyBinding) TableName() string { + return constant.TablePrefix + "notify_bindings" +} diff --git a/internal/models/notify_way.go b/internal/models/notify_way.go new file mode 100644 index 0000000..df004ba --- /dev/null +++ b/internal/models/notify_way.go @@ -0,0 +1,20 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// NotifyWay 消息推送渠道 +type NotifyWay struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:100;not null"` + Type string `json:"type" gorm:"size:50;not null;index"` + Config BigText `json:"config"` + Enabled *bool `json:"enabled" gorm:"default:true;index"` + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (NotifyWay) TableName() string { + return constant.TablePrefix + "notify_ways" +} diff --git a/internal/models/send_stats.go b/internal/models/send_stats.go new file mode 100644 index 0000000..eee2959 --- /dev/null +++ b/internal/models/send_stats.go @@ -0,0 +1,18 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// SendStats 任务执行统计 +type SendStats struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + TaskID string `json:"task_id" gorm:"size:20;uniqueIndex:idx_task_day_status"` + Day string `json:"day" gorm:"size:10;uniqueIndex:idx_task_day_status"` // 格式: 2006-01-02 + Status string `json:"status" gorm:"size:20;uniqueIndex:idx_task_day_status"` + Num int `json:"num" gorm:"default:0"` +} + +func (SendStats) TableName() string { + return constant.TablePrefix + "send_stats" +} diff --git a/internal/models/setting.go b/internal/models/setting.go new file mode 100644 index 0000000..0501510 --- /dev/null +++ b/internal/models/setting.go @@ -0,0 +1,17 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// Setting 系统设置 +type Setting struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Section string `json:"section" gorm:"size:50;not null;index:idx_section_key"` + Key string `json:"key" gorm:"size:100;not null;index:idx_section_key"` + Value BigText `json:"value"` +} + +func (Setting) TableName() string { + return constant.TablePrefix + "settings" +} diff --git a/internal/models/task.go b/internal/models/task.go new file mode 100644 index 0000000..2f6820f --- /dev/null +++ b/internal/models/task.go @@ -0,0 +1,196 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "github.com/engigu/taskpool/internal/constant" +) + +// TaskLanguages 自定义语言配置列表类型,处理 JSON 序列化 +type TaskLanguages []map[string]string + +func (t TaskLanguages) Value() (driver.Value, error) { + if t == nil { + return "[]", nil + } + b, err := json.Marshal(t) + return string(b), err +} + +func (t *TaskLanguages) Scan(v interface{}) error { + if v == nil { + *t = nil + return nil + } + var data []byte + switch s := v.(type) { + case string: + data = []byte(s) + case []byte: + data = s + default: + return fmt.Errorf("invalid type for TaskLanguages: %T", v) + } + return json.Unmarshal(data, t) +} + +// CleanConfig 清理配置结构 +type CleanConfig struct { + Type string `json:"type"` // "day" 或 "count" + Keep int `json:"keep"` // 保留天数或条数 +} + +// RepoConfig 仓库同步配置 +type RepoConfig struct { + SourceType string `json:"source_type"` // url 或 git + SourceURL string `json:"source_url"` // 源地址 + TargetPath string `json:"target_path"` // 目标路径 + Branch string `json:"branch"` // Git 分支 + SparsePath string `json:"sparse_path"` // 稀疏检出路径(仅拉取指定目录或文件) + SingleFile bool `json:"single_file"` // 单文件模式(直接下载文件而非 sparse-checkout) + Proxy string `json:"proxy"` // 代理类型: none, ghproxy, mirror, custom + ProxyURL string `json:"proxy_url"` // 自定义代理地址 + AuthToken string `json:"auth_token"` // 认证 Token + WhitelistPaths string `json:"whitelist_paths"` // 同步时保留的路径及脚本筛选白名单关键词,逗号或竖线分割 + Blacklist string `json:"blacklist"` // 脚本筛选黑名单关键词,竖线分割 + Dependence string `json:"dependence"` // 脚本依赖文件关键词,竖线分割 + Extensions string `json:"extensions"` // 脚本文件后缀关键词,竖线分割 + AutoAddCron bool `json:"auto_add_cron"` // 自动解析脚本注释添加定时任务 + CommentToTask string `json:"commenttotask"` // 兼容 QL 格式任务脚本注释解析 + RepoSource string `json:"repo_source"` // 仓库来源,如果是选择了这个 ql 导入的仓库,= ql + RepoDirName string `json:"repo_dir_name"` // 自定义仓库目录名 +} + +// TaskConfig 任务配置 RepoConfig+TaskConfig=task.config +type TaskConfig struct { + Concurrency int `json:"$task_concurrency"` // 0: disable concurrency, 1: enable concurrency + AllEnvs bool `json:"$task_all_envs"` // 开启则注入全部环境变量 +} + +// Task 代表一个计划任务 +type Task struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Name string `json:"name" gorm:"size:255;not null"` + Remark string `json:"remark" gorm:"size:255;default:''"` + PinType string `json:"pin_type" gorm:"size:20;default:none;index"` // 置顶类型: constant.PinTypeNone, constant.PinTypeTop + Command BigText `json:"command"` // 普通任务的命令 + PreCommand BigText `json:"pre_command"` // 执行前的命令 + PostCommand BigText `json:"post_command"` // 执行后的命令 + Tags string `json:"tags" gorm:"-"` // 标签,逗号分隔 + Type string `json:"type" gorm:"size:20;default:'task'"` // 任务类型: constant.TaskTypeNormal, constant.TaskTypeRepo + TriggerType string `json:"trigger_type" gorm:"size:25;default:'cron'"` // 触发类型: constant.TriggerTypeCron, constant.TriggerTypeTaskPoolStartup + Config BigText `json:"config"` // 配置 JSON(仓库同步配置等) + Schedule string `json:"schedule" gorm:"size:100"` // cron 表达式 + Timeout int `json:"timeout" gorm:"default:30"` // 超时时间(分钟),默认30分钟 + WorkDir string `json:"work_dir" gorm:"size:255;default:''"` // 工作目录,为空则使用 scripts 目录 + CleanConfig string `json:"clean_config" gorm:"size:255;default:''"` // 清理配置 JSON + Envs BigText `json:"envs" gorm:"-"` // 环境变量ID列表,逗号分隔 + Languages TaskLanguages `json:"languages" gorm:"type:text"` // 针对本地任务的语言配置列表 + AgentID *string `json:"agent_id" gorm:"size:20;index"` // Agent ID,为空表示本地执行 + RetryCount int `json:"retry_count" gorm:"default:0"` // 失败重试次数 + RetryInterval int `json:"retry_interval" gorm:"default:0"` // 失败重试间隔(秒) + RandomRange int `json:"random_range" gorm:"default:0"` // 随机延迟范围(秒) + Enabled *bool `json:"enabled" gorm:"default:true"` + RunningGo BigText `json:"running_go"` // 正在运行的 go routine id 数组 (JSON) + RuntimeEnvs []string `json:"-" gorm:"-"` // 运行时环境变量(非持久化) + RuntimeSecrets []string `json:"-" gorm:"-"` // 运行时安全机密(非持久化) + LastRun *LocalTime `json:"last_run"` + NextRun *LocalTime `json:"next_run"` + SourceID string `json:"source_id" gorm:"size:255;index"` // 脚本资源唯一标识(路径 sanitized) + RepoTaskID string `json:"repo_task_id" gorm:"size:20;index"` // 所属的仓库任务 ID + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (t *Task) IsRunning() bool { + if string(t.RunningGo) == "" || string(t.RunningGo) == "[]" { + return false + } + return true +} + +func (Task) TableName() string { + return constant.TablePrefix + "tasks" +} + +func (t *Task) GetID() string { + return t.ID +} + +func (t *Task) GetName() string { + return t.Name +} + +func (t *Task) GetCommand() string { + return string(t.Command) +} + +func (t *Task) GetPreCommand() string { + return string(t.PreCommand) +} + +func (t *Task) GetPostCommand() string { + return string(t.PostCommand) +} + +func (t *Task) GetTimeout() int { + return t.Timeout +} + +func (t *Task) GetWorkDir() string { + return t.WorkDir +} + +func (t *Task) GetEnvs() string { + return string(t.Envs) +} + +func (t *Task) GetLanguages() []map[string]string { + return []map[string]string(t.Languages) +} + +func (t *Task) GetEnvVars() []string { + return t.RuntimeEnvs +} + +func (t *Task) GetSecrets() []string { + return t.RuntimeSecrets +} + +func (t *Task) GetUseMise() bool { + return t.AgentID == nil || *t.AgentID == "" +} + +func (t *Task) UseMise() bool { + return t.GetUseMise() +} + +// CronTask 计划任务接口 +func (t *Task) GetSchedule() string { + return t.Schedule +} + +func (t *Task) GetRandomRange() int { + return t.RandomRange +} + +// TaskLog 代表任务执行的日志记录 +type TaskLog struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + TaskID string `json:"task_id" gorm:"size:20;index"` + AgentID *string `json:"agent_id" gorm:"size:20;index"` // Agent ID,为空表示本地执行 + Command BigText `json:"command"` + Output BigText `json:"-"` // gzip+base64 压缩后的日志 + Error BigText `json:"error"` // 额外的系统错误信息 + Status string `json:"status" gorm:"size:20;index"` // success, failed + Duration int64 `json:"duration"` // 执行耗时(毫秒) + ExitCode int `json:"exit_code"` + StartTime *LocalTime `json:"start_time"` + EndTime *LocalTime `json:"end_time"` + CreatedAt LocalTime `json:"created_at"` +} + +func (TaskLog) TableName() string { + return constant.TablePrefix + "task_logs" +} diff --git a/internal/models/time.go b/internal/models/time.go new file mode 100644 index 0000000..70dbdfa --- /dev/null +++ b/internal/models/time.go @@ -0,0 +1,74 @@ +package models + +import ( + "database/sql/driver" + "fmt" + "time" + + "github.com/engigu/taskpool/internal/systime" +) + +const TimeFormat = "2006-01-02 15:04:05" + +// LocalTime 自定义时间类型,JSON 序列化为 "年-月-日 时:分:秒" 格式 +type LocalTime time.Time + +func (t LocalTime) MarshalJSON() ([]byte, error) { + tt := time.Time(t) + if tt.IsZero() { + return []byte("null"), nil + } + // 统一输出为东八区时间 + tt = systime.InCST(tt) + return []byte(fmt.Sprintf(`"%s"`, tt.Format(TimeFormat))), nil +} + +func (t *LocalTime) UnmarshalJSON(data []byte) error { + if string(data) == "null" || string(data) == `""` { + return nil + } + // 去掉引号 + s := string(data) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + s = s[1 : len(s)-1] + } + tt, err := time.ParseInLocation(TimeFormat, s, time.Local) + if err != nil { + // 尝试解析 ISO 格式 + tt, err = time.Parse(time.RFC3339, s) + if err != nil { + return err + } + } + *t = LocalTime(tt) + return nil +} + +func (t LocalTime) Value() (driver.Value, error) { + return time.Time(t), nil +} + +func (t *LocalTime) Scan(v interface{}) error { + if v == nil { + return nil + } + switch val := v.(type) { + case time.Time: + *t = LocalTime(val) + case string: + tt, err := time.ParseInLocation(TimeFormat, val, time.Local) + if err != nil { + return err + } + *t = LocalTime(tt) + } + return nil +} + +func (t LocalTime) Time() time.Time { + return time.Time(t) +} + +func Now() LocalTime { + return LocalTime(systime.Now()) +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..6b28c5c --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,21 @@ +package models + +import ( + "github.com/engigu/taskpool/internal/constant" +) + +// User represents a system user +type User struct { + ID string `json:"id" gorm:"primaryKey;size:20"` + Username string `json:"username" gorm:"size:100;uniqueIndex;not null"` + Password string `json:"password" gorm:"size:255;not null"` + Email string `json:"email" gorm:"size:255"` + Role string `json:"role" gorm:"size:20;default:user"` // admin, user + TokenVersion int `json:"-" gorm:"default:1"` // 用于 JWT 失效校验 + CreatedAt LocalTime `json:"created_at"` + UpdatedAt LocalTime `json:"updated_at"` +} + +func (User) TableName() string { + return constant.TablePrefix + "users" +} diff --git a/internal/models/vo/agent_vo.go b/internal/models/vo/agent_vo.go new file mode 100644 index 0000000..518f8e0 --- /dev/null +++ b/internal/models/vo/agent_vo.go @@ -0,0 +1,144 @@ +package vo + +import ( + "time" + + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +// AgentVO 代理视图对象 +type AgentVO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + LastSeen *models.LocalTime `json:"last_seen"` + IP string `json:"ip"` + Version string `json:"version"` + BuildTime string `json:"build_time"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + ForceUpdate bool `json:"force_update"` + Enabled bool `json:"enabled"` + SchedulerConfig *AgentSchedulerConfigVO `json:"scheduler_config"` + CreatedAt models.LocalTime `json:"created_at"` + UpdatedAt models.LocalTime `json:"updated_at"` + // 隐藏 Token 和 MachineID +} + +// ToAgentVO 将 Agent 模型转换为 AgentVO +func ToAgentVO(agent *models.Agent) *AgentVO { + if agent == nil { + return nil + } + var schedulerConfigVO *AgentSchedulerConfigVO + if agent.SchedulerConfig.WorkerCount > 0 { + schedulerConfigVO = &AgentSchedulerConfigVO{ + WorkerCount: agent.SchedulerConfig.WorkerCount, + QueueSize: agent.SchedulerConfig.QueueSize, + RateInterval: int(agent.SchedulerConfig.RateInterval / time.Millisecond), + Verbose: agent.SchedulerConfig.Verbose, + StrictQueue: agent.SchedulerConfig.StrictQueue, + } + } + return &AgentVO{ + ID: agent.ID, + Name: agent.Name, + Description: agent.Description, + Status: agent.Status, + LastSeen: agent.LastSeen, + IP: agent.IP, + Version: agent.Version, + BuildTime: agent.BuildTime, + Hostname: agent.Hostname, + OS: agent.OS, + Arch: agent.Arch, + ForceUpdate: agent.ForceUpdate, + Enabled: utils.DerefBool(agent.Enabled, true), + SchedulerConfig: schedulerConfigVO, + CreatedAt: agent.CreatedAt, + UpdatedAt: agent.UpdatedAt, + } +} + +// ToAgentVOList 将 Agent 模型列表转换为 AgentVO 列表 +func ToAgentVOList(agents []*models.Agent) []*AgentVO { + if agents == nil { + return nil + } + vos := make([]*AgentVO, len(agents)) + for i, a := range agents { + vos[i] = ToAgentVO(a) + } + return vos +} + +// ToAgentVOListFromModels 将 Agent 模型列表转换为 AgentVO 列表 +func ToAgentVOListFromModels(agents []models.Agent) []*AgentVO { + vos := make([]*AgentVO, len(agents)) + for i := range agents { + vos[i] = ToAgentVO(&agents[i]) + } + return vos +} + +// AgentTokenVO 代理令牌视图对象 +type AgentTokenVO struct { + ID string `json:"id"` + Token string `json:"token"` + Remark string `json:"remark"` + MaxUses int `json:"max_uses"` + UsedCount int `json:"used_count"` + ExpiresAt *models.LocalTime `json:"expires_at"` + Enabled bool `json:"enabled"` + CreatedAt models.LocalTime `json:"created_at"` +} + +// ToAgentTokenVO 将 AgentToken 模型转换为 AgentTokenVO +func ToAgentTokenVO(token *models.AgentToken) *AgentTokenVO { + if token == nil { + return nil + } + return &AgentTokenVO{ + ID: token.ID, + Token: token.Token, + Remark: token.Remark, + MaxUses: token.MaxUses, + UsedCount: token.UsedCount, + ExpiresAt: token.ExpiresAt, + Enabled: utils.DerefBool(token.Enabled, true), + CreatedAt: token.CreatedAt, + } +} + +// ToAgentTokenVOList 将 AgentToken 模型列表转换为 AgentTokenVO 列表 +func ToAgentTokenVOList(tokens []*models.AgentToken) []*AgentTokenVO { + if tokens == nil { + return nil + } + vos := make([]*AgentTokenVO, len(tokens)) + for i, t := range tokens { + vos[i] = ToAgentTokenVO(t) + } + return vos +} + +// ToAgentTokenVOListFromModels 将 AgentToken 模型列表转换为 AgentTokenVO 列表 +func ToAgentTokenVOListFromModels(tokens []models.AgentToken) []*AgentTokenVO { + vos := make([]*AgentTokenVO, len(tokens)) + for i := range tokens { + vos[i] = ToAgentTokenVO(&tokens[i]) + } + return vos +} + +// AgentSchedulerConfigVO 调度配置视图对象 +type AgentSchedulerConfigVO struct { + WorkerCount int `json:"worker_count"` + QueueSize int `json:"queue_size"` + RateInterval int `json:"rate_interval"` // 毫秒 + Verbose bool `json:"verbose"` + StrictQueue bool `json:"strict_queue"` +} diff --git a/internal/models/vo/dep_vo.go b/internal/models/vo/dep_vo.go new file mode 100644 index 0000000..51fd0b4 --- /dev/null +++ b/internal/models/vo/dep_vo.go @@ -0,0 +1,47 @@ +package vo + +import ( + "time" + + "github.com/engigu/taskpool/internal/models" +) + +// DependencyVO 依赖包视图对象 +type DependencyVO struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Language string `json:"language"` + LangVersion string `json:"lang_version"` + Remark string `json:"remark"` + Log string `json:"log,omitempty"` // 仅在需要时返回 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToDependencyVO 将 Dependency 模型转换为 DependencyVO +func ToDependencyVO(dep *models.Dependency) *DependencyVO { + if dep == nil { + return nil + } + return &DependencyVO{ + ID: dep.ID, + Name: dep.Name, + Version: dep.Version, + Language: dep.Language, + LangVersion: dep.LangVersion, + Remark: dep.Remark, + Log: string(dep.Log), + CreatedAt: dep.CreatedAt, + UpdatedAt: dep.UpdatedAt, + } +} + +// ToDependencyVOListFromModels 将 Dependency 模型列表转换为 DependencyVO 列表 +func ToDependencyVOListFromModels(deps []models.Dependency) []*DependencyVO { + vos := make([]*DependencyVO, len(deps)) + for i := range deps { + vos[i] = ToDependencyVO(&deps[i]) + } + return vos +} diff --git a/internal/models/vo/script_vo.go b/internal/models/vo/script_vo.go new file mode 100644 index 0000000..d1aba32 --- /dev/null +++ b/internal/models/vo/script_vo.go @@ -0,0 +1,37 @@ +package vo + +import ( + "github.com/engigu/taskpool/internal/models" +) + +// ScriptVO 脚本视图对象 +type ScriptVO struct { + ID string `json:"id"` + Name string `json:"name"` + Content string `json:"content,omitempty"` // 仅在拉取详情时返回 + CreatedAt models.LocalTime `json:"created_at"` + UpdatedAt models.LocalTime `json:"updated_at"` +} + +// ToScriptVO 将 Script 模型转换为 ScriptVO +func ToScriptVO(script *models.Script) *ScriptVO { + if script == nil { + return nil + } + return &ScriptVO{ + ID: script.ID, + Name: script.Name, + Content: string(script.Content), + CreatedAt: script.CreatedAt, + UpdatedAt: script.UpdatedAt, + } +} + +// ToScriptVOListFromModels 将 Script 模型列表转换为 ScriptVO 列表 +func ToScriptVOListFromModels(scripts []models.Script) []*ScriptVO { + vos := make([]*ScriptVO, len(scripts)) + for i := range scripts { + vos[i] = ToScriptVO(&scripts[i]) + } + return vos +} diff --git a/internal/models/vo/sys_vo.go b/internal/models/vo/sys_vo.go new file mode 100644 index 0000000..4b3715f --- /dev/null +++ b/internal/models/vo/sys_vo.go @@ -0,0 +1,108 @@ +package vo + +import ( + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +// UserVO 用户视图对象 +type UserVO struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt models.LocalTime `json:"created_at"` + UpdatedAt models.LocalTime `json:"updated_at"` +} + +// ToUserVO 将 User 模型转换为 UserVO +func ToUserVO(user *models.User) *UserVO { + if user == nil { + return nil + } + return &UserVO{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +// EnvVO 环境变量视图对象 +type EnvVO struct { + ID string `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + Remark string `json:"remark"` + Type string `json:"type"` + Tags string `json:"tags"` + Hidden bool `json:"hidden"` + Enabled bool `json:"enabled"` + CreatedAt models.LocalTime `json:"created_at"` + UpdatedAt models.LocalTime `json:"updated_at"` +} + +// ToEnvVO 将 Env 模型转换为 EnvVO +func ToEnvVO(env *models.EnvironmentVariable) *EnvVO { + if env == nil { + return nil + } + val := string(env.Value) + if env.Type == constant.EnvTypeSecret { + val = "********" + } + return &EnvVO{ + ID: env.ID, + Name: env.Name, + Value: val, + Remark: env.Remark, + Type: env.Type, + Tags: env.Tags, + Hidden: utils.DerefBool(env.Hidden, true), + Enabled: utils.DerefBool(env.Enabled, true), + CreatedAt: env.CreatedAt, + UpdatedAt: env.UpdatedAt, + } +} + +// ToEnvVOList 将 Env 模型列表转换为 EnvVO 列表 +func ToEnvVOList(envs []*models.EnvironmentVariable) []*EnvVO { + if envs == nil { + return nil + } + vos := make([]*EnvVO, len(envs)) + for i, e := range envs { + vos[i] = ToEnvVO(e) + } + return vos +} + +// ToEnvVOListFromModels 将 Env 模型列表转换为 EnvVO 列表 +func ToEnvVOListFromModels(envs []models.EnvironmentVariable) []*EnvVO { + vos := make([]*EnvVO, len(envs)) + for i := range envs { + vos[i] = ToEnvVO(&envs[i]) + } + return vos +} + +// LoginLogVO 登录日志视图对象 +type LoginLogVO struct { + ID string `json:"id"` + Username string `json:"username"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Status string `json:"status"` + Message string `json:"message"` + CreatedAt models.LocalTime `json:"created_at"` +} + +// TokenConfig Token 配置结构体 +type TokenConfig struct { + Enabled bool `json:"enabled"` + Token string `json:"token"` + ExpireAt string `json:"expire_at"` +} diff --git a/internal/models/vo/task_vo.go b/internal/models/vo/task_vo.go new file mode 100644 index 0000000..7b582d4 --- /dev/null +++ b/internal/models/vo/task_vo.go @@ -0,0 +1,263 @@ +package vo + +import ( + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +// TaskCreateReq 任务创建请求 +type TaskCreateReq struct { + Name string `json:"name" binding:"required" example:"测试任务"` + Remark string `json:"remark" example:"备注信息"` + Command string `json:"command" example:"echo 'Hello World'"` + PreCommand string `json:"pre_command" example:"echo 'pre'"` + PostCommand string `json:"post_command" example:"echo 'post'"` + Tags string `json:"tags" example:"test,dev"` + Type string `json:"type" example:"repo"` // 可以是 common, repo 等 + Config string `json:"config" swaggertype:"string" example:"{\"source_url\":\"https://github.com/abc/repo\",\"branch\":\"main\"}"` + Schedule string `json:"schedule" example:"0 0 * * *"` + Timeout int `json:"timeout" example:"3600"` + WorkDir string `json:"work_dir" example:"/tmp"` + CleanConfig string `json:"clean_config" example:"true"` + Envs string `json:"envs" example:"{\"ENV_VAR\":\"value\"}"` + Languages models.TaskLanguages `json:"languages"` + AgentID *string `json:"agent_id" example:"agent-1"` + TriggerType string `json:"trigger_type" example:"cron"` + RetryCount int `json:"retry_count" example:"3"` + RetryInterval int `json:"retry_interval" example:"60"` + RandomRange int `json:"random_range" example:"10"` + PinType string `json:"pin_type" example:"time"` +} + +// TaskUpdateReq 任务更新请求 +type TaskUpdateReq struct { + Name string `json:"name" example:"测试任务"` + Remark string `json:"remark" example:"备注信息"` + Command string `json:"command" example:"echo 'Hello World'"` + PreCommand string `json:"pre_command" example:"echo 'pre'"` + PostCommand string `json:"post_command" example:"echo 'post'"` + Tags string `json:"tags" example:"test,dev"` + Type string `json:"type" example:"repo"` + Config string `json:"config" swaggertype:"string" example:"{\"source_url\":\"https://github.com/abc/repo\",\"branch\":\"main\"}"` + Schedule string `json:"schedule" example:"0 0 * * *"` + Timeout int `json:"timeout" example:"3600"` + WorkDir string `json:"work_dir" example:"/tmp"` + CleanConfig string `json:"clean_config" example:"true"` + Envs string `json:"envs" example:"{\"ENV_VAR\":\"value\"}"` + Enabled bool `json:"enabled" example:"true"` + Languages models.TaskLanguages `json:"languages"` + AgentID *string `json:"agent_id" example:"agent-1"` + TriggerType string `json:"trigger_type" example:"cron"` + RetryCount int `json:"retry_count" example:"3"` + RetryInterval int `json:"retry_interval" example:"60"` + RandomRange int `json:"random_range" example:"10"` + PinType string `json:"pin_type" example:"time"` +} + +// TaskVO 任务视图对象 +type TaskVO struct { + ID string `json:"id"` + Name string `json:"name"` + Remark string `json:"remark"` + Command string `json:"command"` + PreCommand string `json:"pre_command"` + PostCommand string `json:"post_command"` + Tags string `json:"tags"` + Type string `json:"type"` + TriggerType string `json:"trigger_type"` + Config string `json:"config"` + Schedule string `json:"schedule"` + Timeout int `json:"timeout"` + WorkDir string `json:"work_dir"` + CleanConfig string `json:"clean_config"` + Envs string `json:"envs"` + Languages models.TaskLanguages `json:"languages"` + AgentID *string `json:"agent_id"` + RepoTaskID string `json:"repo_task_id"` + Enabled bool `json:"enabled"` + RetryCount int `json:"retry_count"` + RetryInterval int `json:"retry_interval"` + RandomRange int `json:"random_range"` + PinType string `json:"pin_type"` + LastRun *models.LocalTime `json:"last_run"` + NextRun *models.LocalTime `json:"next_run"` + CreatedAt models.LocalTime `json:"created_at"` + UpdatedAt models.LocalTime `json:"updated_at"` + RunningStatus string `json:"running_status"` +} + +// ToTaskVO 将 Task 模型转换为 TaskVO +func ToTaskVO(task *models.Task) *TaskVO { + if task == nil { + return nil + } + return &TaskVO{ + ID: task.ID, + Name: task.Name, + Remark: task.Remark, + Command: string(task.Command), + PreCommand: string(task.PreCommand), + PostCommand: string(task.PostCommand), + Tags: task.Tags, + Type: task.Type, + TriggerType: task.TriggerType, + Config: string(task.Config), + Schedule: task.Schedule, + Timeout: task.Timeout, + WorkDir: task.WorkDir, + CleanConfig: task.CleanConfig, + Envs: string(task.Envs), + Languages: task.Languages, + AgentID: task.AgentID, + RepoTaskID: task.RepoTaskID, + Enabled: utils.DerefBool(task.Enabled, true), + RetryCount: task.RetryCount, + RetryInterval: task.RetryInterval, + RandomRange: task.RandomRange, + PinType: task.PinType, + LastRun: task.LastRun, + NextRun: task.NextRun, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + RunningStatus: func() string { + if task.IsRunning() { + return "running" + } + return "idle" + }(), + } +} + +// ToTaskVOList 将 Task 模型列表转换为 TaskVO 列表 +func ToTaskVOList(tasks []*models.Task) []*TaskVO { + if tasks == nil { + return nil + } + vos := make([]*TaskVO, len(tasks)) + for i, t := range tasks { + vos[i] = ToTaskVO(t) + } + return vos +} + +// ToTaskVOListFromModels 将 Task 模型列表转换为 TaskVO 列表 +func ToTaskVOListFromModels(tasks []models.Task) []*TaskVO { + vos := make([]*TaskVO, len(tasks)) + for i := range tasks { + vos[i] = ToTaskVO(&tasks[i]) + } + return vos +} + +// TaskLogVO 任务历史视图对象 +type TaskLogVO struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + TaskType string `json:"task_type"` + AgentID *string `json:"agent_id"` + Command string `json:"command"` + Error string `json:"error"` + Status string `json:"status"` + Duration int64 `json:"duration"` + ExitCode int `json:"exit_code"` + StartTime *models.LocalTime `json:"start_time"` + EndTime *models.LocalTime `json:"end_time"` + CreatedAt models.LocalTime `json:"created_at"` + Output string `json:"output,omitempty"` +} + +// ToTaskLogVO 将 TaskLog 模型转换为 TaskLogVO +// Note: This function assumes the Task field within models.TaskLog is preloaded +// or that taskName and taskType are provided from an external source. +func ToTaskLogVO(log *models.TaskLog) *TaskLogVO { + if log == nil { + return nil + } + return &TaskLogVO{ + ID: log.ID, + TaskID: log.TaskID, + AgentID: log.AgentID, + Command: string(log.Command), + Error: string(log.Error), + Status: log.Status, + Duration: log.Duration, + ExitCode: log.ExitCode, + StartTime: log.StartTime, + EndTime: log.EndTime, + CreatedAt: log.CreatedAt, + Output: string(log.Output), + } +} + +// ToTaskLogVOList 将 TaskLog 模型列表转换为 TaskLogVO 列表 +func ToTaskLogVOList(logs []*models.TaskLog) []*TaskLogVO { + if logs == nil { + return nil + } + vos := make([]*TaskLogVO, len(logs)) + for i, l := range logs { + vos[i] = ToTaskLogVO(l) + } + return vos +} + +// ToTaskLogVOListFromModels 将 TaskLog 模型列表转换为 TaskLogVO 列表 +func ToTaskLogVOListFromModels(logs []models.TaskLog) []*TaskLogVO { + vos := make([]*TaskLogVO, len(logs)) + for i := range logs { + vos[i] = ToTaskLogVO(&logs[i]) + } + return vos +} + +// ExecutionResultVO 任务执行结果视图对象 +type ExecutionResultVO struct { + TaskID string `json:"task_id"` + LogID string `json:"log_id,omitempty"` + Success bool `json:"success"` + Status string `json:"status"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Duration int64 `json:"duration,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` +} + +// ToExecutionResultVO 将 ExecutionResult 转换为 ExecutionResultVO +func ToExecutionResultVO(res *executor.ExecutionResult) *ExecutionResultVO { + if res == nil { + return nil + } + vo := &ExecutionResultVO{ + TaskID: res.TaskID, + LogID: res.LogID, + Success: res.Success, + Status: res.Status, + Output: res.Output, + Error: res.Error, + Duration: res.Duration, + ExitCode: res.ExitCode, + } + if !res.StartTime.IsZero() { + vo.StartTime = res.StartTime.Format("2006-01-02 15:04:05") + } + if !res.EndTime.IsZero() { + vo.EndTime = res.EndTime.Format("2006-01-02 15:04:05") + } + return vo +} + +// ToExecutionResultVOList 将 ExecutionResult 列表转换为 ExecutionResultVO 列表 +func ToExecutionResultVOList(results []executor.ExecutionResult) []*ExecutionResultVO { + if results == nil { + return nil + } + vos := make([]*ExecutionResultVO, len(results)) + for i := range results { + vos[i] = ToExecutionResultVO(&results[i]) + } + return vos +} diff --git a/internal/models/vo/ws_vo.go b/internal/models/vo/ws_vo.go new file mode 100644 index 0000000..8945799 --- /dev/null +++ b/internal/models/vo/ws_vo.go @@ -0,0 +1,8 @@ +package vo + +// WSMessage 通用 WebSocket 消息结构 +type WSMessage struct { + Type string `json:"type"` // 事件类型: task_status, notice, system_stats + Timestamp int64 `json:"timestamp"` // 毫秒时间戳 + Payload interface{} `json:"payload"` // 负载数据 +} diff --git a/internal/router/api_routes.go b/internal/router/api_routes.go new file mode 100644 index 0000000..0defbde --- /dev/null +++ b/internal/router/api_routes.go @@ -0,0 +1,350 @@ +package router + +import ( + "github.com/engigu/taskpool/internal/middleware" + "github.com/gin-gonic/gin" +) + +func initPublicAPIRoutes(api *gin.RouterGroup, c *Controllers) { + // Health check (无需认证) + api.GET("/ping", func(ctx *gin.Context) { + ctx.JSON(200, gin.H{"message": "pong"}) + }) + + // Install routes (无需认证,仅在未安装时可用) + install := api.Group("/install") + { + install.GET("/status", c.Install.GetInstallStatus) + install.POST("", c.Install.Install) + } + + // api.GET("/debug/goroutines", func(ctx *gin.Context) { + // buf := make([]byte, 1024*1024) + // n := runtime.Stack(buf, true) + // ctx.Data(200, "text/plain; charset=utf-8", buf[:n]) + // }) + + // Authentication routes (无需认证) + auth := api.Group("/auth") + { + auth.POST("/login", c.Auth.Login) + auth.POST("/logout", c.Auth.Logout) + // auth.POST("/register", c.Auth.Register) + } + + // 公开的站点设置(无需认证) + api.GET("/settings/public", c.Settings.GetPublicSiteSettings) + + // 隧道模式 (被控端反向连入,使用独立 Token 做 WebSocket 鉴权) + api.GET("/interconnect/tunnel", c.Interconnect.HandleTunnel) + // 子节点主动上报监控数据 (无中间件鉴权,内部鉴权) + api.POST("/interconnect/report", c.Interconnect.ReportMonitorData) + + // 内部使用的 API(仅限本地调用,无需 Bearer 认证) + internalAPI := api.Group("/internal") + internalAPI.Use(middleware.LocalhostOnly()) + { + internalAPI.POST("/tasks/sync-repo-status", c.Task.SyncRepoTasks) + internalAPI.POST("/tasks/execute/:id", c.Executor.ExecuteTask) + internalAPI.POST("/tasks/toggle/:id", c.Task.ToggleTask) + } +} + +func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) { + authorized := api.Group("") + authorized.Use(middleware.AuthRequired()) + { + // 获取当前用户 (普通用户即可访问) + authorized.GET("/auth/me", c.Auth.GetCurrentUser) + + // 以下管理接口需要管理员权限 + adminOnly := authorized.Group("") + adminOnly.Use(middleware.AdminRequired()) + { + registerDashboardRoutes(adminOnly, c) + registerTaskRoutes(adminOnly, c) + registerEnvRoutes(adminOnly, c) + registerScriptRoutes(adminOnly, c) + registerFileRoutes(adminOnly, c) + registerLogRoutes(adminOnly, c) + registerTerminalRoutes(adminOnly, c) + registerSettingsRoutes(adminOnly, c) + registerDependencyRoutes(adminOnly, c) + registerAgentRoutes(adminOnly, c) + registerMiseRoutes(adminOnly, c) + registerNotificationRoutes(adminOnly, c) + registerAppLogRoutes(adminOnly, c) + registerSystemWSRoutes(adminOnly, c) + registerWebUIRoutes(adminOnly, c) + registerMonitorRoutes(adminOnly, c) + registerInterconnectRoutes(adminOnly, c) + registerSystemRoutes(adminOnly, c) + } + } + + // 通知发送 API(使用通知 Token 认证,供脚本调用) + notifyAPI := api.Group("/notify") + notifyAPI.Use(middleware.NotifyTokenAuth()) + { + notifyAPI.POST("/send", c.Notification.SendNotification) + } +} + +func registerDashboardRoutes(g *gin.RouterGroup, c *Controllers) { + g.GET("/stats", c.Dashboard.GetStats) + g.GET("/sentence", c.Dashboard.GetSentence) + g.GET("/sendstats", c.Dashboard.GetSendStats) + g.GET("/taskstats", c.Dashboard.GetTaskStats) +} + +func registerTaskRoutes(g *gin.RouterGroup, c *Controllers) { + tasks := g.Group("/tasks") + { + tasks.POST("", c.Task.CreateTask) + tasks.GET("", c.Task.GetTasks) + tasks.GET("/:id", c.Task.GetTask) + tasks.POST("/bulk_save", c.Task.BulkSaveTask) + tasks.PUT("/:id", c.Task.UpdateTask) + tasks.DELETE("/:id", c.Task.DeleteTask) + tasks.POST("/batch-delete", c.Task.BatchDeleteTasks) + tasks.DELETE("/batch-by-query", c.Task.BatchDeleteByQuery) + tasks.POST("/stop/:logID", c.Task.StopTask) + tasks.GET("/tags", c.Task.GetTags) + } + + execution := g.Group("/execute") + { + execution.POST("/task/:id", c.Executor.ExecuteTask) + execution.POST("/command", c.Executor.ExecuteCommand) + execution.GET("/results", c.Executor.GetLastResults) + } +} + +func registerEnvRoutes(g *gin.RouterGroup, c *Controllers) { + env := g.Group("/env") + { + env.GET("/secret-status", c.Env.GetSecretStatus) + env.GET("/tags", c.Env.GetTags) + env.POST("", c.Env.CreateEnvVar) + env.POST("/bulk_save", c.Env.BulkSaveEnv) + env.GET("", c.Env.GetEnvVars) + env.GET("/all", c.Env.GetAllEnvVars) + env.GET("/:id", c.Env.GetEnvVar) + env.GET("/:id/tasks", c.Env.GetAssociatedTasks) + env.PUT("/:id", c.Env.UpdateEnvVar) + env.DELETE("/:id", c.Env.DeleteEnvVar) + } +} + +func registerScriptRoutes(g *gin.RouterGroup, c *Controllers) { + scripts := g.Group("/scripts") + { + scripts.POST("", c.Script.CreateScript) + scripts.GET("", c.Script.GetScripts) + scripts.GET("/:id", c.Script.GetScript) + scripts.PUT("/:id", c.Script.UpdateScript) + scripts.DELETE("/:id", c.Script.DeleteScript) + } +} + +func registerFileRoutes(g *gin.RouterGroup, c *Controllers) { + files := g.Group("/files") + { + files.GET("/tree", c.File.GetFileTree) + files.GET("/content", c.File.GetFileContent) + files.GET("/download", c.File.DownloadFile) + files.GET("/download-zip", c.File.DownloadZip) + files.POST("/content", c.File.SaveFileContent) + files.POST("/create", c.File.CreateFile) + files.POST("/delete", c.File.DeleteFile) + files.POST("/rename", c.File.RenameFile) + files.POST("/move", c.File.MoveFile) + files.POST("/copy", c.File.CopyFile) + files.POST("/upload", c.File.UploadArchive) + files.POST("/uploadfiles", c.File.UploadFiles) + } +} + +func registerLogRoutes(g *gin.RouterGroup, c *Controllers) { + logs := g.Group("/logs") + { + logs.GET("", c.Log.GetLogs) + logs.POST("/clear", c.Log.ClearLogs) + logs.GET("/sse", c.LogSSE.StreamLog) + logs.GET("/:id", c.Log.GetLogDetail) + logs.DELETE("/:id", c.Log.DeleteLog) + } +} + +func registerTerminalRoutes(g *gin.RouterGroup, c *Controllers) { + g.GET("/terminal/ws", c.Terminal.HandleWebSocket) + // g.POST("/terminal/exec", c.Terminal.ExecuteShellCommand) // 暂未使用,已注释 + g.GET("/terminal/cmds", c.Terminal.GetCommands) +} + +func registerSettingsRoutes(g *gin.RouterGroup, c *Controllers) { + settings := g.Group("/settings") + { + settings.POST("/password", c.Settings.ChangePassword) + settings.GET("/site", c.Settings.GetSiteSettings) + settings.PUT("/site", c.Settings.UpdateSiteSettings) + settings.POST("/site/openapi-token/generate", c.Settings.GenerateOpenapiToken) + settings.GET("/paths", c.Settings.GetPaths) + settings.GET("/scheduler", c.Settings.GetSchedulerSettings) + settings.PUT("/scheduler", c.Settings.UpdateSchedulerSettings) + settings.GET("/about", c.Settings.GetAbout) + settings.GET("/changelog", c.Settings.GetChangelog) + settings.GET("/loginlogs", c.Settings.GetLoginLogs) + settings.POST("/backup", c.Settings.CreateBackup) + settings.GET("/backup/status", c.Settings.GetBackupStatus) + settings.GET("/backup/download", c.Settings.DownloadBackup) + settings.POST("/restore", c.Settings.RestoreBackup) + // 通用设置接口 + settings.GET("/:section", c.Settings.GetSectionSettings) + settings.PUT("/:section", c.Settings.UpdateSectionSettings) + settings.GET("/:section/:key", c.Settings.GetSetting) + settings.POST("/:section/:key/generate", c.Settings.GenerateSettingToken) + } +} + +func registerDependencyRoutes(g *gin.RouterGroup, c *Controllers) { + deps := g.Group("/deps") + { + deps.GET("", c.Dependency.List) + deps.POST("", c.Dependency.Create) + deps.DELETE("/:id", c.Dependency.Delete) + deps.POST("/install", c.Dependency.Install) + deps.POST("/install-cmd", c.Dependency.GetInstallCommand) + deps.POST("/uninstall/:id", c.Dependency.Uninstall) + deps.POST("/reinstall/:id", c.Dependency.Reinstall) + deps.POST("/reinstall-all", c.Dependency.ReinstallAll) + deps.POST("/reinstall-all-cmd", c.Dependency.GetReinstallAllCommand) + deps.POST("/batch-install-cmd", c.Dependency.GetBatchInstallCommand) + deps.POST("/import", c.Dependency.ParseAndImport) + deps.GET("/installed", c.Dependency.GetInstalled) + deps.GET("/install-suggest-cmd", c.Dependency.GetDepInstallCommand) + } +} + +func registerAgentRoutes(g *gin.RouterGroup, c *Controllers) { + agents := g.Group("/agents") + { + agents.GET("", c.Agent.List) + agents.GET("/version", c.Agent.GetVersion) + agents.PUT("/:id", c.Agent.Update) + agents.DELETE("/:id", c.Agent.Delete) + agents.POST("/:id/token", c.Agent.RegenerateToken) + agents.POST("/:id/update", c.Agent.ForceUpdate) + // 令牌管理 + agents.GET("/tokens", c.Agent.ListTokens) + agents.POST("/tokens", c.Agent.CreateToken) + agents.DELETE("/tokens/:id", c.Agent.DeleteToken) + } + + // Agent API(供前端调用,保持在 v1 下) + agentAPIv1 := g.Group("/agent") + { + agentAPIv1.GET("/download", c.Agent.Download) + } +} + +func registerMiseRoutes(g *gin.RouterGroup, c *Controllers) { + mise := g.Group("/mise") + { + mise.GET("/ls", c.Mise.List) + mise.POST("/sync", c.Mise.Sync) + mise.GET("/plugins", c.Mise.Plugins) + mise.GET("/versions", c.Mise.Versions) + mise.GET("/verify-cmd", c.Mise.VerifyCommand) + mise.POST("/use-global", c.Mise.UseGlobal) + mise.POST("/unset-global", c.Mise.UnsetGlobal) + mise.GET("/envs", c.Mise.Envs) + mise.POST("/envs", c.Mise.SetEnv) + mise.DELETE("/envs", c.Mise.UnsetEnv) + } +} + +func registerNotificationRoutes(g *gin.RouterGroup, c *Controllers) { + notify := g.Group("/notify") + { + notify.GET("/types", c.Notification.GetChannelTypes) + notify.GET("/channels", c.Notification.GetChannels) + notify.POST("/channels", c.Notification.SaveChannel) + notify.DELETE("/channels/:id", c.Notification.DeleteChannel) + notify.POST("/channels/test", c.Notification.TestChannel) + notify.GET("/bindings", c.Notification.GetBindings) + notify.POST("/bindings", c.Notification.SaveBinding) + notify.POST("/bindings/batch", c.Notification.BatchSaveBindings) + notify.DELETE("/bindings/:id", c.Notification.DeleteBinding) + } +} + +func registerAppLogRoutes(g *gin.RouterGroup, c *Controllers) { + appLogs := g.Group("/app-logs") + { + appLogs.GET("", c.AppLog.GetLogs) + appLogs.POST("/read", c.AppLog.MarkAsRead) + appLogs.POST("/clear", c.AppLog.ClearLogs) + } +} + +func registerSystemWSRoutes(g *gin.RouterGroup, c *Controllers) { + g.GET("/ws/events", c.SystemWS.HandleEvents) +} + +func registerMonitorRoutes(g *gin.RouterGroup, c *Controllers) { + monitor := g.Group("/monitor") + { + monitor.GET("", c.Monitor.GetSystemMonitor) + monitor.GET("/sse", c.Monitor.MonitorSSE) + } +} + +func initAgentAPIRoutes(root *gin.RouterGroup, c *Controllers) { + // Agent API(供远程 Agent 调用,不使用 /v1 版本号) + agentAPI := root.Group("/api/agent") + { + agentAPI.POST("/heartbeat", c.Agent.Heartbeat) + agentAPI.GET("/tasks", c.Agent.GetTasks) + agentAPI.POST("/report", c.Agent.ReportResult) + agentAPI.GET("/download", c.Agent.Download) // 也在这里注册,兼容 Agent 调用 + agentAPI.GET("/ws", c.Agent.WSConnect) // WebSocket 连接 + } +} + +func registerWebUIRoutes(g *gin.RouterGroup, c *Controllers) { + webuiGroup := g.Group("/webui") + { + webuiGroup.GET("", c.WebUI.GetWebUIs) + webuiGroup.POST("/upload", c.WebUI.UploadWebUI) + webuiGroup.PUT("/active", c.WebUI.SetActiveWebUI) + webuiGroup.DELETE("/:name", c.WebUI.DeleteWebUI) + } +} + +func registerInterconnectRoutes(g *gin.RouterGroup, c *Controllers) { + interconnect := g.Group("/interconnect") + { + interconnect.GET("/nodes", c.Interconnect.GetNodes) + interconnect.POST("/nodes", c.Interconnect.CreateNode) + interconnect.PUT("/nodes/:id", c.Interconnect.UpdateNode) + interconnect.DELETE("/nodes/:id", c.Interconnect.DeleteNode) + interconnect.GET("/nodes/:id/status", c.Interconnect.GetNodeStatus) + interconnect.POST("/sync/script", c.Interconnect.SyncScript) + interconnect.POST("/sync/env", c.Interconnect.SyncEnv) + interconnect.POST("/sync/task", c.Interconnect.SyncTask) + + interconnect.GET("/child/status", c.Interconnect.GetChildStatus) + + // 代理模式 (面板穿越) + interconnect.Any("/proxy/:node_id/*path", c.Interconnect.ProxyRequest) + } +} + +func registerSystemRoutes(g *gin.RouterGroup, c *Controllers) { + systemAPI := g.Group("/system") + { + systemAPI.POST("/export", c.Data.ExportBusinessData) + systemAPI.POST("/import", c.Data.ImportBusinessData) + } +} diff --git a/internal/router/events.go b/internal/router/events.go new file mode 100644 index 0000000..2c22fc3 --- /dev/null +++ b/internal/router/events.go @@ -0,0 +1,28 @@ +package router + +import ( + // "fmt" + + // "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/eventbus" + // "github.com/engigu/taskpool/internal/logger" + // "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/executor" +) + +func setupEventHandlers(subscribers ...eventbus.Subscriber) { + bus := eventbus.DefaultBus + + // 遍历并统一初始化所有订阅者的事件链路 + for _, s := range subscribers { + s.SubscribeEvents(bus) + } +} + +func startAppLogCleanup(appLogSvc *services.AppLogService) { + // 注册到内部系统定时器(并立即执行第一次) + executor.GetSysCron().AddJobWithRun("@every 1h", func() { + appLogSvc.CleanUp() + }) +} diff --git a/internal/router/openapi.go b/internal/router/openapi.go new file mode 100644 index 0000000..9b19116 --- /dev/null +++ b/internal/router/openapi.go @@ -0,0 +1,84 @@ +package router + +import ( + "github.com/engigu/taskpool/internal/middleware" + "github.com/gin-gonic/gin" +) + +// initOpenAPIV1Routes 初始化 OpenAPI v1 路由 +// 只注册有 @Tags OpenAPI 注释的接口 +func initOpenAPIV1Routes(root *gin.RouterGroup, c *Controllers) { + // OpenAPI v1 路由组 (使用 Bearer Token) + open := root.Group("/open2api/v1") + open.Use(middleware.OpenapiRequired()) + { + // 任务相关接口 + registerOpenAPITaskRoutes(open, c) + // 环境变量相关接口 + registerOpenAPIEnvRoutes(open, c) + // 脚本相关接口 + registerOpenAPIScriptRoutes(open, c) + // 日志相关接口 + registerOpenAPILogRoutes(open, c) + // 任务执行相关接口 + registerOpenAPIExecutorRoutes(open, c) + } +} + +// registerOpenAPITaskRoutes 注册 OpenAPI 任务路由(只包含有 @Tags OpenAPI 注释的接口) +func registerOpenAPITaskRoutes(g *gin.RouterGroup, c *Controllers) { + tasks := g.Group("/tasks") + { + tasks.POST("", c.Task.CreateTask) + tasks.GET("", c.Task.GetTasks) + tasks.GET("/:id", c.Task.GetTask) + tasks.PUT("/:id", c.Task.UpdateTask) + tasks.DELETE("/:id", c.Task.DeleteTask) + tasks.POST("/stop/:logID", c.Task.StopTask) + tasks.GET("/tags", c.Task.GetTags) + } +} + +// registerOpenAPIEnvRoutes 注册 OpenAPI 环境变量路由(只包含有 @Tags OpenAPI 注释的接口) +func registerOpenAPIEnvRoutes(g *gin.RouterGroup, c *Controllers) { + env := g.Group("/env") + { + env.POST("", c.Env.CreateEnvVar) + env.GET("", c.Env.GetEnvVars) + env.GET("/all", c.Env.GetAllEnvVars) + env.GET("/:id", c.Env.GetEnvVar) + env.GET("/:id/tasks", c.Env.GetAssociatedTasks) + env.PUT("/:id", c.Env.UpdateEnvVar) + env.DELETE("/:id", c.Env.DeleteEnvVar) + } +} + +// registerOpenAPILogRoutes 注册 OpenAPI 日志路由(只包含有 @Tags OpenAPI 注释的接口) +func registerOpenAPILogRoutes(g *gin.RouterGroup, c *Controllers) { + logs := g.Group("/logs") + { + logs.GET("", c.Log.GetLogs) + logs.GET("/:id", c.Log.GetLogDetail) + } +} + +// registerOpenAPIExecutorRoutes 注册 OpenAPI 任务执行路由(只包含有 @Tags OpenAPI 注释的接口) +func registerOpenAPIExecutorRoutes(g *gin.RouterGroup, c *Controllers) { + execution := g.Group("/execute") + { + execution.POST("/task/:id", c.Executor.ExecuteTask) + execution.GET("/results", c.Executor.GetLastResults) + } +} + +// registerOpenAPIScriptRoutes 注册 OpenAPI 脚本路由 +func registerOpenAPIScriptRoutes(g *gin.RouterGroup, c *Controllers) { + scripts := g.Group("/scripts") + { + scripts.POST("", c.Script.CreateScript) + scripts.GET("", c.Script.GetScripts) + scripts.GET("/:id", c.Script.GetScript) + scripts.PUT("/:id", c.Script.UpdateScript) + scripts.DELETE("/:id", c.Script.DeleteScript) + } +} diff --git a/internal/router/register.go b/internal/router/register.go new file mode 100644 index 0000000..411f64b --- /dev/null +++ b/internal/router/register.go @@ -0,0 +1,83 @@ +package router + +import ( + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/controllers" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/services/tasks" +) + +var executorService *tasks.ExecutorService + +func RegisterControllers() *Controllers { + // 初始化服务 + settingsService := services.NewSettingsService() + loginLogService := services.NewLoginLogService() + + // 执行系统初始化(返回 userService) + initService := services.NewInitService(settingsService) + userService := initService.Initialize() + + taskService := tasks.NewTaskService() + envService := services.NewEnvService() + scriptService := services.NewScriptService() + sendStatsService := services.NewSendStatsService() + agentWSManager := services.GetAgentWSManager() + systemWSManager := services.GetSystemWSManager() + + taskLogService := tasks.NewTaskLogService(sendStatsService) + // 创建任务执行服务(需要依赖注入) + notifyService := services.NewNotificationService() + appLogService := services.NewAppLogService() + interconnectService := services.NewInterconnectService() + + // 清理 task 运行状态的任务可以直接由 executorService 承担或在此处通过 Database 直接清理 + // 简单期间,我们使用一个新方法 tasks.CleanupRunningTasks() 或者让 executorService 启动时清理 + + executorService = tasks.NewExecutorService(taskService, taskLogService, agentWSManager, settingsService, envService) + // 启动时清理残留的运行状态 + _ = executorService.CleanupRunningTasks() + + // 启动计划任务 + executorService.StartCron() + + // 初始化所有关注系统总线的服务 + setupEventHandlers(appLogService, notifyService, loginLogService, systemWSManager) + startAppLogCleanup(appLogService) + + taskController := controllers.NewTaskController(taskService, executorService) + envController := controllers.NewEnvController(envService) + + // 初始化并返回控制器 + return &Controllers{ + Task: taskController, + Auth: controllers.NewAuthController(userService, settingsService, loginLogService), + Env: envController, + Script: controllers.NewScriptController(scriptService), + Executor: controllers.NewExecutorController(executorService), + File: controllers.NewFileController(constant.ScriptsWorkDir), + Dashboard: controllers.NewDashboardController(executorService), + Log: controllers.NewLogController(), + LogSSE: controllers.NewLogSSEController(), + Terminal: controllers.NewTerminalController(envService), + Settings: controllers.NewSettingsController(userService, loginLogService, executorService), + Dependency: controllers.NewDependencyController(), + Agent: controllers.NewAgentController(settingsService), + Mise: controllers.NewMiseController(services.NewMiseService()), + Notification: controllers.NewNotificationController(), + AppLog: controllers.NewAppLogController(), + SystemWS: controllers.NewSystemWSController(), + WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)), + Monitor: controllers.NewMonitorController(executorService), + Interconnect: controllers.NewInterconnectController(interconnectService), + Data: controllers.NewDataController(taskController, envController), + Install: controllers.NewInstallController(), + } +} + +// StopCron 停止计划任务服务 +func StopCron() { + if executorService != nil { + executorService.Stop() + } +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..623b30f --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,120 @@ +package router + +import ( + "os" + "strings" + + "github.com/engigu/taskpool/internal/controllers" + "github.com/engigu/taskpool/internal/middleware" + "github.com/engigu/taskpool/internal/services" + + "github.com/gin-contrib/pprof" + "github.com/gin-gonic/gin" +) + +type Controllers struct { + Task *controllers.TaskController + Auth *controllers.AuthController + Env *controllers.EnvController + Script *controllers.ScriptController + Executor *controllers.ExecutorController + File *controllers.FileController + Dashboard *controllers.DashboardController + Log *controllers.LogController + LogSSE *controllers.LogSSEController + Terminal *controllers.TerminalController + Settings *controllers.SettingsController + Dependency *controllers.DependencyController + Agent *controllers.AgentController + Mise *controllers.MiseController + Notification *controllers.NotificationController + AppLog *controllers.AppLogController + SystemWS *controllers.SystemWSController + WebUI *controllers.WebUIController + Monitor *controllers.MonitorController + Interconnect *controllers.InterconnectController + Data *controllers.DataController + Install *controllers.InstallController +} + +func Setup(c *Controllers) *gin.Engine { + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + router := gin.New() + router.Use(middleware.GinLogger(), middleware.GinRecovery()) + router.Use(middleware.TravelProxyMiddleware()) + + // 获取 URL 前缀 + cfg := services.GetConfig() + urlPrefix := strings.TrimSuffix(cfg.Server.URLPrefix, "/") + + // 创建一个路由组,如果有前缀则使用前缀,否则使用根路径 + var root *gin.RouterGroup + if urlPrefix != "" { + root = router.Group(urlPrefix) + } else { + root = router.Group("") + } + + // 按需绑定 Pprof 调试路由 (注册在 root 下以支持 URLPrefix) + if cfg.Server.PprofEnabled { + // pprof.RouteRegister 会在传入的路由组下注册 /debug/pprof 等路由 + pprof.RouteRegister(root) + } + + // ========================================================================= + // 路由分类组装 (对应 Nginx 的 location 块分发) + // ========================================================================= + + // 1. [ location /assets ] 静态资源路由 + initStaticRoutes(root) + + // 3. [ location /api ] 内部 API 路由组 + apiV1 := root.Group("/api/v1") + initPublicAPIRoutes(apiV1, c) // 公开接口 (无需认证) + initAuthorizedAPIRoutes(apiV1, c) // 授权接口 (需 JWT) + + // 4. [ location /api/agent ] Agent 相关 API 路由组 + initAgentAPIRoutes(root, c) + initOpenAPIV1Routes(root, c) + + // ========================================================================= + // [ location / ] 全局 404 兜底与 SPA 渲染 + // 对应 Nginx: try_files $uri $uri/ /index.html; + // ========================================================================= + router.NoRoute(func(ctx *gin.Context) { + path := ctx.Request.URL.Path + + // 如果配置了前缀,只处理带前缀的路径 + if urlPrefix != "" && !strings.HasPrefix(path, urlPrefix) { + ctx.Status(404) + return + } + + // 解析实际的相对路径 + relPath := strings.TrimPrefix(path, urlPrefix) + if !strings.HasPrefix(relPath, "/") { + relPath = "/" + relPath + } + + // 拦截器:不该返回 index.html 的情况 + // 如果该请求被识别为 API 请求、静态资源请求,或者是带有明确文件后缀(如 .js / .css / .png)的物理文件请求 + // 都不应该返回 SPA 页面(会报前端 MIME 类型错误),而是直接掐断返回 404 + hasAnyExt := false + if idx := strings.LastIndex(relPath, "."); idx > 0 && len(relPath)-idx < 6 { + // 简单判断是否有后缀(如 .js, .css) + hasAnyExt = true + } + + if strings.HasPrefix(relPath, "/api/") || strings.HasPrefix(relPath, "/assets/") || strings.HasPrefix(relPath, "/debug/") || hasAnyExt { + ctx.String(404, "404 Not Found") + return + } + + // 其他所有有效的前端页面路径(如 /tasks, /settings),都返回 index.html 交给 vue-router 处理 + serveSPA(ctx, urlPrefix, 200) + }) + + return router +} diff --git a/internal/router/static_routes.go b/internal/router/static_routes.go new file mode 100644 index 0000000..e9808b2 --- /dev/null +++ b/internal/router/static_routes.go @@ -0,0 +1,268 @@ +package router + +import ( + "compress/gzip" + "encoding/json" + "io" + "io/fs" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/services" + "github.com/engigu/taskpool/internal/static" + + "github.com/gin-gonic/gin" +) + +// cacheControl 返回设置 Cache-Control header 的中间件 +func cacheControl(value string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", value) + c.Next() + } +} + +func openFileWithWebui(filename string) (fs.File, error) { + webuiSvc := services.NewWebUIService(services.NewSettingsService()) + if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil { + // 如果启用了定义的前端包,去取定义的路径 + return customFS.Open(filename) + } + + // 如果是默认的,取默认路径 + defaultFS := static.GetFS() + if defaultFS == nil { + return nil, fs.ErrNotExist + } + return defaultFS.Open(filename) +} + +func readFileWithWebui(filename string) ([]byte, error) { + webuiSvc := services.NewWebUIService(services.NewSettingsService()) + if customFS := webuiSvc.GetActiveWebUIFS(); customFS != nil { + // 如果启用了定义的前端包,去取定义的路径 + return fs.ReadFile(customFS, filename) + } + + // 如果是默认的,取默认路径 + defaultFS := static.GetFS() + if defaultFS == nil { + return nil, fs.ErrNotExist + } + return fs.ReadFile(defaultFS, filename) +} + +func initStaticRoutes(root *gin.RouterGroup) { + + // 专门处理 /assets 目录下的资源 + root.GET("/assets/*filepath", cacheControl("public, max-age=31536000, immutable"), func(ctx *gin.Context) { + fullPath := "assets" + ctx.Param("filepath") + fullPath = strings.TrimPrefix(fullPath, "/") + + isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") + gzPath := fullPath + ".gz" + + // 确定 MIME 类型 + ext := filepath.Ext(fullPath) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + switch ext { + case ".js": + contentType = "application/javascript" + case ".css": + contentType = "text/css" + case ".svg": + contentType = "image/svg+xml" + default: + contentType = "application/octet-stream" + } + } + + // 优先尝试读取 .gz 文件 + if gzFile, err := openFileWithWebui(gzPath); err == nil { + defer gzFile.Close() + ctx.Header("Content-Type", contentType) + + if isGzipSupported { + // 极致性能:流式透传压缩包 (RSS 占用极低) + ctx.Header("Content-Encoding", "gzip") + ctx.Status(http.StatusOK) + io.Copy(ctx.Writer, gzFile) + } else { + // 兼容处理:流式解压发送 + gr, _ := gzip.NewReader(gzFile) + defer gr.Close() + ctx.Status(http.StatusOK) + io.Copy(ctx.Writer, gr) + } + return + } + + // 如果没有 .gz,流式读取原文件 + if file, err := openFileWithWebui(fullPath); err == nil { + defer file.Close() + ctx.Header("Content-Type", contentType) + ctx.Status(http.StatusOK) + io.Copy(ctx.Writer, file) + return + } + + ctx.Status(404) + }) + + // logo.svg 等单文件处理 + root.GET("/logo.svg", func(ctx *gin.Context) { + settings := services.NewSettingsService() + icon := settings.Get(constant.SectionSite, constant.KeyIcon) + if icon != "" { + ctx.Header("Cache-Control", "public, max-age=86400") + ctx.Data(http.StatusOK, "image/svg+xml", []byte(icon)) + return + } + serveSingleFile(ctx, "logo.svg", "image/svg+xml", "public, max-age=86400") + }) + + // PWA 相关路由处理 + initPWARoutes(root) +} + +func initPWARoutes(root *gin.RouterGroup) { + // PWA 相关文件处理 + pwaRootFiles := map[string]string{ + "/sw.js": "application/javascript", + "/registerSW.js": "application/javascript", + "/favicon.ico": "image/x-icon", + "/pwa-icon-192.png": "image/png", + "/pwa-icon-512.png": "image/png", + } + + for path, contentType := range pwaRootFiles { + pPath := path + pType := contentType + root.GET(pPath, func(ctx *gin.Context) { + file := strings.TrimPrefix(pPath, "/") + serveSingleFile(ctx, file, pType, "public, no-cache") + }) + } + + // 动态 manifest 处理 (支持由 Go 后端控制标题和图标) + root.GET("/manifest.webmanifest", handleManifest) + + // 动态匹配 workbox-*.js (Vite PWA 生成的库文件) + root.GET("/workbox-:hash.js", func(ctx *gin.Context) { + file := "workbox-" + ctx.Param("hash") + ".js" + serveSingleFile(ctx, file, "application/javascript", "public, max-age=31536000, immutable") + }) +} + +func handleManifest(ctx *gin.Context) { + + // 读取原始 manifest + data, err := readFileWithWebui("manifest.webmanifest") + if err != nil { + ctx.Status(404) + return + } + + var manifest map[string]interface{} + if err := json.Unmarshal(data, &manifest); err != nil { + // 如果解析失败,回退到原始文件 + ctx.Data(200, "application/manifest+json", data) + return + } + + // 注入后端配置的标题 + settings := services.NewSettingsService() + title := settings.Get(constant.SectionSite, constant.KeyTitle) + if title != "" { + manifest["name"] = title + manifest["short_name"] = title + } + + // 注入后端配置的图标 (首选 logo.svg) + manifest["icons"] = []map[string]interface{}{ + { + "src": "/logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable", + }, + } + + res, _ := json.Marshal(manifest) + ctx.Header("Cache-Control", "public, no-cache") + ctx.Data(200, "application/manifest+json", res) +} + +func serveSingleFile(ctx *gin.Context, filename string, contentType string, cache string) { + + if cache != "" { + ctx.Header("Cache-Control", cache) + } + ctx.Header("Content-Type", contentType) + + isGzipSupported := strings.Contains(ctx.GetHeader("Accept-Encoding"), "gzip") + + // 尝试流式发送压缩版 + if gzFile, err := openFileWithWebui(filename + ".gz"); err == nil { + defer gzFile.Close() + if isGzipSupported { + ctx.Header("Content-Encoding", "gzip") + ctx.Status(200) + io.Copy(ctx.Writer, gzFile) + } else { + gr, _ := gzip.NewReader(gzFile) + defer gr.Close() + ctx.Status(200) + io.Copy(ctx.Writer, gr) + } + return + } + + // 尝试流式发送原版 + if file, err := openFileWithWebui(filename); err == nil { + defer file.Close() + ctx.Status(200) + io.Copy(ctx.Writer, file) + return + } + + ctx.Status(404) +} + +// serveSPA 注入配置并返回 index.html 给前端渲染 +func serveSPA(ctx *gin.Context, urlPrefix string, status int) { + + var data []byte + // index.html 较小且需要修改字符串,可以一次性读入内存 + if gzFile, err := openFileWithWebui("index.html.gz"); err == nil { + defer gzFile.Close() + gr, _ := gzip.NewReader(gzFile) + data, _ = io.ReadAll(gr) + gr.Close() + } else if file, err := openFileWithWebui("index.html"); err == nil { + defer file.Close() + data, _ = io.ReadAll(file) + } + + if data == nil { + ctx.String(status, "index.html not found.") + return + } + + html := string(data) + baseHref := urlPrefix + "/" + if urlPrefix == "" { + baseHref = "/" + } + html = strings.Replace(html, "", "\n ", 1) + configScript := `` + html = strings.Replace(html, "", configScript+"", 1) + + ctx.Header("Content-Type", "text/html; charset=utf-8") + ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate") + ctx.Data(status, "text/html; charset=utf-8", []byte(html)) +} diff --git a/internal/sdk/message/bark.go b/internal/sdk/message/bark.go new file mode 100644 index 0000000..457341c --- /dev/null +++ b/internal/sdk/message/bark.go @@ -0,0 +1,244 @@ +package message + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type barkResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Bark struct { + PushKey string + Archive string + Group string + Sound string + Icon string + Level string + URL string + Key string + IV string + Server string + Badge string + Copy string + AutoCopy string + ProxyURL string // 可选的代理地址 +} + +func (b *Bark) Request(title, content string) ([]byte, error) { + data := map[string]interface{}{ + "device_key": b.PushKey, + "title": title, + "body": content, + } + if b.Archive != "" { + data["isArchive"] = b.Archive + } + if b.Group != "" { + data["group"] = b.Group + } + if b.Sound != "" { + data["sound"] = b.Sound + } + if b.Icon != "" { + data["icon"] = b.Icon + } + if b.Level != "" { + data["level"] = b.Level + } + if b.URL != "" { + data["url"] = b.URL + } + if b.Badge != "" { + data["badge"] = b.Badge + } + if b.Copy != "" { + data["copy"] = b.Copy + } + if b.AutoCopy != "" { + data["autoCopy"] = b.AutoCopy + } + + server := b.Server + if server == "" { + server = "https://api.day.app" + } + server = strings.TrimSuffix(server, "/") + apiURL := server + "/push" + + // If PushKey is a full URL, we might be using an old-style custom URL + if strings.HasPrefix(b.PushKey, "http") { + apiURL = b.PushKey + } + + var postData interface{} + if b.Key != "" && b.IV != "" { + // Encrypted Request + // 1. Prepare the full notification payload (without device_key, as specified for encryption) + encryptData := make(map[string]interface{}) + for k, v := range data { + if k != "device_key" { + encryptData[k] = v + } + } + + jsonData, err := json.Marshal(encryptData) + if err != nil { + return nil, err + } + + ciphertext, err := b.encryptPayload(string(jsonData)) + if err != nil { + return nil, fmt.Errorf("encryption failed: %v", err) + } + + postData = map[string]interface{}{ + "ciphertext": ciphertext, + "iv": b.IV, + "device_key": b.PushKey, + } + } else { + // Normal request + postData = data + } + + jsonData, err := json.Marshal(postData) + if err != nil { + return nil, err + } + + // 使用带超时的客户端 + client := b.getHTTPClient() + resp, err := client.Post(apiURL, "application/json;charset=utf-8", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r barkResponse + err = json.Unmarshal(body, &r) + if err != nil { + // If not JSON, return the raw body as it might be a simple success message from some servers + if resp.StatusCode == 200 { + return body, nil + } + return body, err + } + + if r.Code != 200 && resp.StatusCode != 200 { + return body, fmt.Errorf("bark response error: %s", string(body)) + } + return body, nil +} + +func (b *Bark) encryptPayload(payload string) (string, error) { + key := []byte(b.Key) + iv := []byte(b.IV) + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + paddedPayload := b.pkcs7Pad([]byte(payload), aes.BlockSize) + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(paddedPayload)) + mode.CryptBlocks(ciphertext, paddedPayload) + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (b *Bark) pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padtext...) +} + +// getHTTPClient 获取 HTTP 客户端(含超时和代理) +func (b *Bark) getHTTPClient() *http.Client { + client := &http.Client{ + Timeout: 20 * time.Second, + } + + if b.ProxyURL != "" { + proxyURL, err := url.Parse(b.ProxyURL) + if err == nil { + if strings.HasPrefix(strings.ToLower(b.ProxyURL), "socks5://") { + dialer, err := b.createSOCKS5Dialer(proxyURL) + if err == nil { + client.Transport = &http.Transport{ + DialContext: dialer.DialContext, + } + } + } else { + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + } + } + + return client +} + +// createSOCKS5Dialer 创建 SOCKS5 拨号器 +func (b *Bark) createSOCKS5Dialer(proxyURL *url.URL) (proxy.ContextDialer, error) { + host := proxyURL.Host + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{ + User: proxyURL.User.Username(), + Password: password, + } + } + + baseDialer := &net.Dialer{ + Timeout: 20 * time.Second, + KeepAlive: 20 * time.Second, + } + + dialer, err := proxy.SOCKS5("tcp", host, auth, baseDialer) + if err != nil { + return nil, err + } + + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, errors.New("failed to convert to ContextDialer") + } + + return contextDialer, nil +} diff --git a/internal/sdk/message/custom.go b/internal/sdk/message/custom.go new file mode 100644 index 0000000..c3c87ae --- /dev/null +++ b/internal/sdk/message/custom.go @@ -0,0 +1,58 @@ +package message + +import ( + "bytes" + "io" + "net/http" + "time" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type CustomWebhook struct { + Webhook string + Body string +} + +var Client = &http.Client{ + Timeout: 5 * time.Second, +} + +func (cw *CustomWebhook) Request(url string, msg string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(msg))) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := Client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + + } + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, err +} diff --git a/internal/sdk/message/dtalk.go b/internal/sdk/message/dtalk.go new file mode 100644 index 0000000..298d90a --- /dev/null +++ b/internal/sdk/message/dtalk.go @@ -0,0 +1,145 @@ +package message + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type response struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` +} + +type Dtalk struct { + AccessToken string + Secret string +} + +func (t *Dtalk) Request(msg interface{}) ([]byte, error) { + b, err := json.Marshal(msg) + if err != nil { + return nil, err + } + resp, err := http.Post(t.getURL(), "application/json", bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + + } + }(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var r response + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + if r.Code != 0 { + return body, fmt.Errorf("response error: %s", string(body)) + } + return body, err +} + +// SendMessageText Function to send message +func (t *Dtalk) SendMessageText(text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{ + "content": text, + }, + } + + // 添加@功能 + if len(at) > 0 { + atMobiles := []string{} + isAtAll := false + + for _, mobile := range at { + if mobile == "all" || mobile == "@all" { + isAtAll = true + } else { + atMobiles = append(atMobiles, mobile) + } + } + + msg["at"] = map[string]interface{}{ + "atMobiles": atMobiles, + "isAtAll": isAtAll, + } + } + + resp, err := t.Request(msg) + return resp, err +} + +func (t *Dtalk) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]string{ + "title": title, + "text": text, + }, + } + + // 添加@功能 + if len(at) > 0 { + atMobiles := []string{} + isAtAll := false + + for _, mobile := range at { + if mobile == "all" || mobile == "@all" { + isAtAll = true + } else { + atMobiles = append(atMobiles, mobile) + } + } + + msg["at"] = map[string]interface{}{ + "atMobiles": atMobiles, + "isAtAll": isAtAll, + } + } + + resp, err := t.Request(msg) + return resp, err +} + +func (t *Dtalk) hmacSha256(stringToSign string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(stringToSign)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func (t *Dtalk) getURL() string { + wh := "https://oapi.dingtalk.com/robot/send?access_token=" + t.AccessToken + timestamp := time.Now().UnixNano() / 1e6 + stringToSign := fmt.Sprintf("%d\n%s", timestamp, t.Secret) + sign := t.hmacSha256(stringToSign, t.Secret) + url := fmt.Sprintf("%s×tamp=%d&sign=%s", wh, timestamp, sign) + return url +} diff --git a/internal/sdk/message/email.go b/internal/sdk/message/email.go new file mode 100644 index 0000000..abcd45a --- /dev/null +++ b/internal/sdk/message/email.go @@ -0,0 +1,62 @@ +package message + +import ( + "fmt" + + "gopkg.in/gomail.v2" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type EmailMessage struct { + Server string + Port int + Account string + Passwd string + FromName string + GM *gomail.Dialer +} + +func (e *EmailMessage) Init(host string, port int, account string, passwd string, fromName string) { + e.Server = host + e.Port = port + e.Account = account + e.Passwd = passwd + e.FromName = fromName + e.GM = gomail.NewDialer(host, port, account, passwd) +} + +func (e *EmailMessage) sendMessage(toEmail string, title string, content string, contentType string) string { + m := gomail.NewMessage() + if e.FromName != "" { + m.SetAddressHeader("From", e.Account, e.FromName) + } else { + m.SetHeader("From", e.Account) + } + m.SetHeader("To", toEmail) + m.SetHeader("Subject", title) + m.SetBody(contentType, content) + + if err := e.GM.DialAndSend(m); err != nil { + return fmt.Sprintf("邮件发送失败: %s", err) + } + return "" +} + +func (e *EmailMessage) SendTextMessage(toEmail string, title string, content string) string { + return e.sendMessage(toEmail, title, content, "text/plain") +} + +func (e *EmailMessage) SendHtmlMessage(toEmail string, title string, content string) string { + return e.sendMessage(toEmail, title, content, "text/html") +} diff --git a/internal/sdk/message/feishu.go b/internal/sdk/message/feishu.go new file mode 100644 index 0000000..fbb1861 --- /dev/null +++ b/internal/sdk/message/feishu.go @@ -0,0 +1,145 @@ +package message + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type feishuResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +type Feishu struct { + AccessToken string + Secret string +} + +// genSign 生成飞书签名 +func (f *Feishu) genSign(timestamp int64) string { + if f.Secret == "" { + return "" + } + stringToSign := fmt.Sprintf("%v\n%s", timestamp, f.Secret) + h := hmac.New(sha256.New, []byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + return signature +} + +// SendMessageText 发送文本消息 +func (f *Feishu) SendMessageText(content string, atMobiles ...string) ([]byte, error) { + timestamp := time.Now().Unix() + sign := f.genSign(timestamp) + + msg := map[string]interface{}{ + "timestamp": strconv.FormatInt(timestamp, 10), + "sign": sign, + "msg_type": "text", + "content": map[string]interface{}{ + "text": content, + }, + } + + return f.send(msg) +} + +// SendMessageMarkdown 发送 Markdown 消息 +func (f *Feishu) SendMessageMarkdown(title, content string, atMobiles ...string) ([]byte, error) { + timestamp := time.Now().Unix() + sign := f.genSign(timestamp) + + // 处理 @ 人员 + atContent := "" + if len(atMobiles) > 0 { + for _, mobile := range atMobiles { + if mobile == "all" { + atContent += "所有人" + } else { + atContent += fmt.Sprintf("", mobile) + } + } + content = atContent + "\n" + content + } + + msg := map[string]interface{}{ + "timestamp": strconv.FormatInt(timestamp, 10), + "sign": sign, + "msg_type": "interactive", + "card": map[string]interface{}{ + "header": map[string]interface{}{ + "title": map[string]interface{}{ + "tag": "plain_text", + "content": title, + }, + }, + "elements": []map[string]interface{}{ + { + "tag": "markdown", + "content": content, + }, + }, + }, + } + + return f.send(msg) +} + +// send 发送请求 +func (f *Feishu) send(msg map[string]interface{}) ([]byte, error) { + url := fmt.Sprintf("https://open.feishu.cn/open-apis/bot/v2/hook/%s", f.AccessToken) + + jsonData, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("JSON序列化失败: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + var result feishuResponse + if err := json.Unmarshal(body, &result); err != nil { + return body, fmt.Errorf("解析响应失败: %v", err) + } + + if result.Code != 0 { + return body, fmt.Errorf("飞书返回错误: %s", result.Msg) + } + + return body, nil +} diff --git a/internal/sdk/message/gotify.go b/internal/sdk/message/gotify.go new file mode 100644 index 0000000..788201d --- /dev/null +++ b/internal/sdk/message/gotify.go @@ -0,0 +1,79 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type gotifyResponse struct { + Id int `json:"id"` + Message string `json:"message"` + ErrorCode int `json:"errorCode"` +} + +type Gotify struct { + Url string + Token string + Priority int +} + +func (g *Gotify) Request(title, content string) ([]byte, error) { + // Construct the URL with token + u, err := url.Parse(fmt.Sprintf("%s/message", g.Url)) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("token", g.Token) + u.RawQuery = q.Encode() + + data := map[string]interface{}{ + "title": title, + "message": content, + "priority": g.Priority, + } + + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + + resp, err := http.Post(u.String(), "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r gotifyResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + + if r.Id == 0 { + return body, fmt.Errorf("gotify response error: %s", string(body)) + } + + return body, nil +} diff --git a/internal/sdk/message/ntfy.go b/internal/sdk/message/ntfy.go new file mode 100644 index 0000000..4d047e7 --- /dev/null +++ b/internal/sdk/message/ntfy.go @@ -0,0 +1,88 @@ +package message + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type Ntfy struct { + Url string + Topic string + Priority string + Icon string + Token string + Username string + Password string + Actions string +} + +func encodeRFC2047(text string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(text)) + return fmt.Sprintf("=?utf-8?B?%s?=", encoded) +} + +func (n *Ntfy) Request(title, content string) ([]byte, error) { + if n.Url == "" { + n.Url = "https://ntfy.sh" + } + url := fmt.Sprintf("%s/%s", n.Url, n.Topic) + + req, err := http.NewRequest("POST", url, bytes.NewBufferString(content)) + if err != nil { + return nil, err + } + + req.Header.Set("Title", encodeRFC2047(title)) + priority := n.Priority + if priority == "" { + priority = "3" + } + req.Header.Set("Priority", priority) + if n.Icon != "" { + req.Header.Set("Icon", n.Icon) + } + if n.Actions != "" { + req.Header.Set("Actions", encodeRFC2047(n.Actions)) + } + + if n.Token != "" { + req.Header.Set("Authorization", "Bearer "+n.Token) + } else if n.Username != "" && n.Password != "" { + authStr := n.Username + ":" + n.Password + encodedAuth := base64.StdEncoding.EncodeToString([]byte(authStr)) + req.Header.Set("Authorization", "Basic "+encodedAuth) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return body, fmt.Errorf("ntfy response error: %s", string(body)) + } + + return body, nil +} diff --git a/internal/sdk/message/pushme.go b/internal/sdk/message/pushme.go new file mode 100644 index 0000000..eb309a1 --- /dev/null +++ b/internal/sdk/message/pushme.go @@ -0,0 +1,63 @@ +package message + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type PushMe struct { + PushKey string + URL string + Date string + Type string +} + +func (p *PushMe) Request(title, content string) (string, error) { + apiURL := p.URL + if apiURL == "" { + apiURL = "https://push.i-i.me/" + } + + data := url.Values{} + data.Set("push_key", p.PushKey) + data.Set("title", title) + data.Set("content", content) + if p.Date != "" { + data.Set("date", p.Date) + } + if p.Type != "" { + data.Set("type", p.Type) + } + + resp, err := http.Post(apiURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode == 200 && string(body) == "success" { + return string(body), nil + } + + return string(body), fmt.Errorf("PushMe response error: %s", string(body)) +} diff --git a/internal/sdk/message/pushplus.go b/internal/sdk/message/pushplus.go new file mode 100644 index 0000000..650f082 --- /dev/null +++ b/internal/sdk/message/pushplus.go @@ -0,0 +1,85 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type PushPlus struct { + Token string `json:"token"` + Topic string `json:"topic,omitempty"` + Template string `json:"template,omitempty"` + Channel string `json:"channel,omitempty"` + Webhook string `json:"webhook,omitempty"` + CallbackUrl string `json:"callbackUrl,omitempty"` + To string `json:"to,omitempty"` +} + +type pushPlusData struct { + PushPlus + Title string `json:"title"` + Content string `json:"content"` +} + +type pushPlusResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data string `json:"data"` +} + +func (p *PushPlus) Request(title, content string) (string, error) { + url := "https://www.pushplus.plus/send" + + data := pushPlusData{ + PushPlus: *p, + Title: title, + Content: content, + } + + body, err := json.Marshal(data) + if err != nil { + return "", err + } + + resp, err := http.Post(url, "application/json", bytes.NewBuffer(body)) + if err != nil { + // Try old URL if first one fails or as fallback + urlOld := "http://pushplus.hxtrip.com/send" + resp, err = http.Post(urlOld, "application/json", bytes.NewBuffer(body)) + if err != nil { + return "", err + } + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var res pushPlusResponse + if err := json.Unmarshal(respBody, &res); err != nil { + return string(respBody), err + } + + if res.Code == 200 { + return string(respBody), nil + } + + return string(respBody), fmt.Errorf("PushPlus error: %s (code: %d)", res.Msg, res.Code) +} diff --git a/internal/sdk/message/qyweixin.go b/internal/sdk/message/qyweixin.go new file mode 100644 index 0000000..26513a5 --- /dev/null +++ b/internal/sdk/message/qyweixin.go @@ -0,0 +1,123 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type qywxResponse struct { + Code int `json:"errcode"` + Msg string `json:"errmsg"` +} + +type QyWeiXin struct { + AccessToken string +} + +func (t *QyWeiXin) Request(msg interface{}) ([]byte, error) { + b, err := json.Marshal(msg) + if err != nil { + return nil, err + } + resp, err := http.Post(t.getURL(), "application/json", bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + + } + }(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var r qywxResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + if r.Code != 0 { + return body, fmt.Errorf("response error: %s", string(body)) + } + return body, err +} + +// SendMessageText Function to send message +func (t *QyWeiXin) SendMessageText(text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "text", + "text": map[string]interface{}{ + "content": text, + }, + } + + // 添加@功能 + // 企业微信支持两种@方式: + // 1. mentioned_list: userid列表或"@all" + // 2. mentioned_mobile_list: 手机号列表 + if len(at) > 0 { + mentionedList := []string{} + mentionedMobileList := []string{} + + for _, item := range at { + if item == "@all" || item == "all" { + mentionedList = append(mentionedList, "@all") + } else if len(item) == 11 && item[0] == '1' { + // 判断是否为手机号(简单判断:11位且以1开头) + mentionedMobileList = append(mentionedMobileList, item) + } else { + // 否则当作userid处理 + mentionedList = append(mentionedList, item) + } + } + + textContent := msg["text"].(map[string]interface{}) + if len(mentionedList) > 0 { + textContent["mentioned_list"] = mentionedList + } + if len(mentionedMobileList) > 0 { + textContent["mentioned_mobile_list"] = mentionedMobileList + } + } + + resp, err := t.Request(msg) + return resp, err +} + +func (t *QyWeiXin) SendMessageMarkdown(title, text string, at ...string) ([]byte, error) { + msg := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]interface{}{ + "content": text, + }, + } + + // 企业微信Markdown消息不支持@功能,但可以在内容中手动添加 + // 如果需要@功能,建议使用text类型 + + resp, err := t.Request(msg) + return resp, err +} + +func (t *QyWeiXin) getURL() string { + url := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=" + t.AccessToken + return url +} diff --git a/internal/sdk/message/telegram.go b/internal/sdk/message/telegram.go new file mode 100644 index 0000000..25ac43d --- /dev/null +++ b/internal/sdk/message/telegram.go @@ -0,0 +1,193 @@ +package message + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type telegramResponse struct { + Ok bool `json:"ok"` + Description string `json:"description"` +} + +type Telegram struct { + BotToken string + ChatID string + ApiHost string // 可选的自定义API地址(优先级最高) + ProxyURL string // 可选的代理地址,支持 http://、https://、socks5:// 格式 +} + +func (t *Telegram) Request(params map[string]interface{}) ([]byte, error) { + apiURL := t.getAPIURL() + + // 构建请求体 + data := url.Values{} + for key, value := range params { + data.Set(key, fmt.Sprintf("%v", value)) + } + + // 创建 HTTP 客户端 + client := t.getHTTPClient() + + resp, err := client.Post(apiURL, "application/x-www-form-urlencoded", bytes.NewBufferString(data.Encode())) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + // 忽略关闭错误 + } + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r telegramResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + + if !r.Ok { + return body, fmt.Errorf("telegram api error: %s", r.Description) + } + + return body, nil +} + +// SendMessageText 发送文本消息 +func (t *Telegram) SendMessageText(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +// SendMessageMarkdown 发送Markdown格式消息 +func (t *Telegram) SendMessageMarkdown(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "parse_mode": "Markdown", + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +// SendMessageHTML 发送HTML格式消息 +func (t *Telegram) SendMessageHTML(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +func (t *Telegram) getAPIURL() string { + // 自定义 API 地址优先级最高 + if t.ApiHost != "" { + return fmt.Sprintf("%s/bot%s/sendMessage", t.ApiHost, t.BotToken) + } + return fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken) +} + +// getHTTPClient 获取配置了代理的 HTTP 客户端 +func (t *Telegram) getHTTPClient() *http.Client { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 如果配置了代理且没有自定义 API 地址,则使用代理 + // 自定义 API 地址优先级更高,通常用于自建代理服务器 + if t.ProxyURL != "" && t.ApiHost == "" { + proxyURL, err := url.Parse(t.ProxyURL) + if err == nil { + // 判断是否为 SOCKS5 代理 + if strings.HasPrefix(strings.ToLower(t.ProxyURL), "socks5://") { + // 使用 SOCKS5 代理 + dialer, err := t.createSOCKS5Dialer(proxyURL) + if err == nil { + client.Transport = &http.Transport{ + DialContext: dialer.DialContext, + } + } + } else { + // 使用 HTTP/HTTPS 代理 + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + } + } + + return client +} + +// createSOCKS5Dialer 创建 SOCKS5 代理拨号器 +func (t *Telegram) createSOCKS5Dialer(proxyURL *url.URL) (proxy.ContextDialer, error) { + // 解析代理地址 + host := proxyURL.Host + + // 检查是否有认证信息 + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{ + User: proxyURL.User.Username(), + Password: password, + } + } + + // 创建基础拨号器 + baseDialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + + // 创建 SOCKS5 拨号器 + dialer, err := proxy.SOCKS5("tcp", host, auth, baseDialer) + if err != nil { + return nil, err + } + + // 转换为 ContextDialer + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, errors.New("failed to convert to ContextDialer") + } + + return contextDialer, nil +} diff --git a/internal/sdk/message/vocechat.go b/internal/sdk/message/vocechat.go new file mode 100644 index 0000000..7cef54c --- /dev/null +++ b/internal/sdk/message/vocechat.go @@ -0,0 +1,74 @@ +package message + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type VoceChat struct { + Server string + APIKey string + TargetType string // "user" or "group" + TargetID string +} + +func (v *VoceChat) Request(title, content string) ([]byte, error) { + if v.Server == "" || v.APIKey == "" || v.TargetID == "" { + return nil, fmt.Errorf("vocechat config missing: server, api_key and target_id are required") + } + + server := strings.TrimSuffix(v.Server, "/") + endpoint := "send_to_user" + if v.TargetType == "group" { + endpoint = "send_to_group" + } + + url := fmt.Sprintf("%s/api/bot/%s/%s", server, endpoint, v.TargetID) + + // Use text/plain for now as requested + body := content + if title != "" { + body = fmt.Sprintf("%s\n\n%s", title, content) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(body))) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("x-api-key", v.APIKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return respBody, fmt.Errorf("vocechat response error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} diff --git a/internal/sdk/message/wechat_account.go b/internal/sdk/message/wechat_account.go new file mode 100644 index 0000000..fb12eb7 --- /dev/null +++ b/internal/sdk/message/wechat_account.go @@ -0,0 +1,75 @@ +package message + +import ( + "github.com/silenceper/wechat/v2" + "github.com/silenceper/wechat/v2/cache" + offConfig "github.com/silenceper/wechat/v2/officialaccount/config" + "github.com/silenceper/wechat/v2/officialaccount/message" + "github.com/sirupsen/logrus" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type WeChatOFAccount struct { + AppID string + AppSecret string + ToUser string + TemplateID string + URL string +} + +// 使用内存缓存进行token的存储 +var memory = cache.NewMemory() + +func (cw *WeChatOFAccount) Send(title string, content string) (string, error) { + wc := wechat.NewWechat() + cfg := &offConfig.Config{ + AppID: cw.AppID, + AppSecret: cw.AppSecret, + Cache: memory, + } + officialAccount := wc.GetOfficialAccount(cfg) + + // 获取 Access Token + _, err := officialAccount.GetAccessToken() + if err != nil { + logrus.Errorf("获取access token失败:%s", err) + return "", err + } + + msgData := make(map[string]*message.TemplateDataItem) + msgData["content"] = &message.TemplateDataItem{ + Value: content, + } + msgData["title"] = &message.TemplateDataItem{ + Value: title, + //Color: "#173177", + } + + // 创建模板消息 + templateMessage := &message.TemplateMessage{ + ToUser: cw.ToUser, + TemplateID: cw.TemplateID, + URL: cw.URL, + Data: msgData, + } + + // 发送模板消息 + _, err = officialAccount.GetTemplate().Send(templateMessage) + if err != nil { + logrus.Errorf("发送模板消息失败: %s", err) + return "", err + } + //logrus.Infof("模板消息发送成功。 消息ID: %d", msgID) + return "", nil +} diff --git a/internal/sdk/message/wxpusher.go b/internal/sdk/message/wxpusher.go new file mode 100644 index 0000000..b7fdc97 --- /dev/null +++ b/internal/sdk/message/wxpusher.go @@ -0,0 +1,74 @@ +package message + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type WxPusher struct { + AppToken string `json:"appToken"` + Content string `json:"content"` + ContentType int `json:"contentType"` + TopicIds []int `json:"topicIds,omitempty"` + Uids []string `json:"uids,omitempty"` + Url string `json:"url,omitempty"` + VerifyPayType int `json:"verifyPayType,omitempty"` +} + +type wxPusherResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data []struct { + Uid string `json:"uid"` + TopicId int `json:"topicId"` + MessageId int `json:"messageId"` + Code int `json:"code"` + Status string `json:"status"` + } `json:"data"` +} + +func (w *WxPusher) Send() (string, error) { + apiUrl := "https://wxpusher.zjiecode.com/api/send/message" + + body, err := json.Marshal(w) + if err != nil { + return "", err + } + + resp, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var res wxPusherResponse + if err := json.Unmarshal(respBody, &res); err != nil { + return string(respBody), err + } + + if res.Code == 1000 { + return string(respBody), nil + } + + return string(respBody), fmt.Errorf("WxPusher error: %s (code: %d)", res.Msg, res.Code) +} diff --git a/internal/sdk/messenger/channels/aliyun_sms.go b/internal/sdk/messenger/channels/aliyun_sms.go new file mode 100644 index 0000000..a8b8865 --- /dev/null +++ b/internal/sdk/messenger/channels/aliyun_sms.go @@ -0,0 +1,52 @@ +package channels + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type AliyunSMSChannel struct{ *BaseChannel } + +func NewAliyunSMSChannel() Channel { + return &AliyunSMSChannel{NewBaseChannel(ChannelAliyunSMS, []string{FormatTypeText})} +} + +func (c *AliyunSMSChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessKeyId := config.GetString("access_key_id") + accessKeySecret := config.GetString("access_key_secret") + signName := config.GetString("sign_name") + regionId := config.GetString("region_id") + phoneNumber := config.GetString("phone_number") + templateCode := config.GetString("template_code") + + if accessKeyId == "" || accessKeySecret == "" || signName == "" { + return SendError("aliyun sms config missing: access_key_id, access_key_secret, sign_name are required"), nil + } + if phoneNumber == "" || templateCode == "" { + return SendError("aliyun sms config missing: phone_number, template_code are required"), nil + } + + _, formattedContent := c.FormatContent(msg) + + if regionId == "" { + regionId = "cn-hangzhou" + } + + client, err := createAliyunSMSClient(accessKeyId, accessKeySecret, regionId) + if err != nil { + return SendError("创建阿里云短信客户端失败: %s", err.Error()), nil + } + + result, err := sendAliyunSMS(client, phoneNumber, signName, templateCode, formattedContent, msg.Extra) + if err != nil { + return ErrorResult("", err), nil + } + return SuccessResult(result), nil +} diff --git a/internal/sdk/messenger/channels/bark.go b/internal/sdk/messenger/channels/bark.go new file mode 100644 index 0000000..f79eca6 --- /dev/null +++ b/internal/sdk/messenger/channels/bark.go @@ -0,0 +1,51 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type BarkChannel struct{ *BaseChannel } + +func NewBarkChannel() Channel { + return &BarkChannel{NewBaseChannel(ChannelBark, []string{FormatTypeText})} +} + +func (c *BarkChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + pushKey := config.GetString("push_key") + if pushKey == "" { + return SendError("bark config missing: push_key is required"), nil + } + + cli := message.Bark{ + PushKey: pushKey, + Archive: config.GetString("archive"), + Group: config.GetString("group"), + Sound: config.GetString("sound"), + Icon: config.GetString("icon"), + Level: config.GetString("level"), + URL: config.GetString("url"), + Key: config.GetString("key"), + IV: config.GetString("iv"), + Server: config.GetString("server"), + Badge: config.GetString("badge"), + Copy: config.GetString("copy"), + AutoCopy: config.GetString("auto_copy"), + ProxyURL: config.GetString("proxy_url"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/channel.go b/internal/sdk/messenger/channels/channel.go new file mode 100644 index 0000000..25dff23 --- /dev/null +++ b/internal/sdk/messenger/channels/channel.go @@ -0,0 +1,86 @@ +package channels + +import "fmt" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +// Channel 渠道接口 - SDK 版本,零业务依赖 +type Channel interface { + // GetType 返回渠道类型标识 + GetType() string + // GetSupportedFormats 返回支持的消息格式 + GetSupportedFormats() []string + // Send 发送消息 + Send(config ChannelConfig, msg *Message) (*Result, error) +} + +// BaseChannel 渠道基础实现 +type BaseChannel struct { + channelType string + supportedFormats []string +} + +func NewBaseChannel(channelType string, supportedFormats []string) *BaseChannel { + return &BaseChannel{channelType: channelType, supportedFormats: supportedFormats} +} + +func (c *BaseChannel) GetType() string { return c.channelType } +func (c *BaseChannel) GetSupportedFormats() []string { return c.supportedFormats } + +// FormatContent 根据渠道支持的格式选择最佳内容 +func (c *BaseChannel) FormatContent(msg *Message) (formatType string, content string) { + for _, ft := range c.supportedFormats { + switch ft { + case FormatTypeMarkdown: + if msg.HasMarkdown() { + return FormatTypeMarkdown, msg.Markdown + } + case FormatTypeHTML: + if msg.HasHTML() { + return FormatTypeHTML, msg.HTML + } + case FormatTypeText: + if msg.HasText() { + return FormatTypeText, msg.Text + } + } + } + if msg.HasText() { + return FormatTypeText, msg.Text + } + return FormatTypeText, "" +} + +// SuccessResult 创建成功结果 +func SuccessResult(response string) *Result { + return &Result{Success: true, Response: response} +} + +// ErrorResult 创建失败结果 +func ErrorResult(response string, err error) *Result { + errMsg := "" + if err != nil { + errMsg = err.Error() + } + return &Result{Success: false, Response: response, Error: errMsg} +} + +// ErrorResultStr 创建失败结果(字符串错误) +func ErrorResultStr(response string, errMsg string) *Result { + return &Result{Success: false, Response: response, Error: errMsg} +} + +// SendError 发送失败时的格式化错误 +func SendError(format string, args ...any) *Result { + return &Result{Success: false, Error: fmt.Sprintf(format, args...)} +} diff --git a/internal/sdk/messenger/channels/custom.go b/internal/sdk/messenger/channels/custom.go new file mode 100644 index 0000000..5e297ee --- /dev/null +++ b/internal/sdk/messenger/channels/custom.go @@ -0,0 +1,59 @@ +package channels + +import ( + "encoding/json" + + "github.com/engigu/taskpool/internal/sdk/message" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type CustomChannel struct{ *BaseChannel } + +func NewCustomChannel() Channel { + return &CustomChannel{NewBaseChannel(ChannelCustom, []string{FormatTypeText})} +} + +func (c *CustomChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + webhook := config.GetString("webhook") + body := config.GetString("body") + headersStr := config.GetString("headers") + + if webhook == "" { + return SendError("custom config missing: webhook is required"), nil + } + + var headers map[string]string + if headersStr != "" { + if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { + return SendError("custom config error: headers must be a valid JSON object"), nil + } + } + + _, formattedContent := c.FormatContent(msg) + cli := message.CustomWebhook{} + + // 替换 body 模板中的 TEXT 占位符 + bodyStr := body + if bodyStr != "" { + bodyStr = replaceBodyPlaceholder(bodyStr, formattedContent) + } else { + bodyStr = formattedContent + } + + res, err := cli.Request(webhook, bodyStr, headers) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/dtalk.go b/internal/sdk/messenger/channels/dtalk.go new file mode 100644 index 0000000..e6ce8f8 --- /dev/null +++ b/internal/sdk/messenger/channels/dtalk.go @@ -0,0 +1,54 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type DtalkChannel struct{ *BaseChannel } + +func NewDtalkChannel() Channel { + return &DtalkChannel{NewBaseChannel(ChannelDtalk, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *DtalkChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessToken := config.GetString("access_token") + secret := config.GetString("secret") + + if accessToken == "" { + return SendError("dtalk config missing: access_token is required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + atMobiles := msg.GetAtMobiles() + if msg.AtAll { + atMobiles = append(atMobiles, "all") + } + + cli := message.Dtalk{AccessToken: accessToken, Secret: secret} + var res []byte + var err error + + switch contentType { + case FormatTypeText: + res, err = cli.SendMessageText(formattedContent, atMobiles...) + case FormatTypeMarkdown: + res, err = cli.SendMessageMarkdown(msg.Title, formattedContent, atMobiles...) + default: + return SendError("未知的钉钉发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/email.go b/internal/sdk/messenger/channels/email.go new file mode 100644 index 0000000..2271721 --- /dev/null +++ b/internal/sdk/messenger/channels/email.go @@ -0,0 +1,62 @@ +package channels + +import ( + "fmt" + "github.com/engigu/taskpool/internal/sdk/message" + "strconv" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type EmailChannel struct{ *BaseChannel } + +func NewEmailChannel() Channel { + return &EmailChannel{NewBaseChannel(ChannelEmail, []string{FormatTypeHTML, FormatTypeText})} +} + +func (c *EmailChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + server := config.GetString("server") + portStr := config.GetString("port") + account := config.GetString("account") + passwd := config.GetString("passwd") + fromName := config.GetString("from_name") + toAccount := config.GetString("to_account") + + if server == "" || account == "" || passwd == "" { + return SendError("email config missing: server, account, passwd are required"), nil + } + if toAccount == "" { + return SendError("email config missing: to_account is required"), nil + } + + port, _ := strconv.Atoi(portStr) + contentType, formattedContent := c.FormatContent(msg) + + var emailer message.EmailMessage + emailer.Init(server, port, account, passwd, fromName) + + var errMsg string + switch contentType { + case FormatTypeText: + errMsg = emailer.SendTextMessage(toAccount, msg.Title, formattedContent) + case FormatTypeHTML: + errMsg = emailer.SendHtmlMessage(toAccount, msg.Title, formattedContent) + default: + errMsg = fmt.Sprintf("未知的邮件发送内容类型:%s", contentType) + } + + if errMsg != "" { + return ErrorResultStr("", errMsg), nil + } + return SuccessResult(""), nil +} diff --git a/internal/sdk/messenger/channels/feishu.go b/internal/sdk/messenger/channels/feishu.go new file mode 100644 index 0000000..9c12fe2 --- /dev/null +++ b/internal/sdk/messenger/channels/feishu.go @@ -0,0 +1,56 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type FeishuChannel struct{ *BaseChannel } + +func NewFeishuChannel() Channel { + return &FeishuChannel{NewBaseChannel(ChannelFeishu, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *FeishuChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessToken := config.GetString("access_token") + secret := config.GetString("secret") + + if accessToken == "" { + return SendError("feishu config missing: access_token is required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + atMobiles := msg.GetAtMobiles() + atUserIds := msg.GetAtUserIds() + atList := append(atMobiles, atUserIds...) + if msg.AtAll { + atList = append(atList, "all") + } + + cli := message.Feishu{AccessToken: accessToken, Secret: secret} + var res []byte + var err error + + switch contentType { + case FormatTypeText: + res, err = cli.SendMessageText(formattedContent, atList...) + case FormatTypeMarkdown: + res, err = cli.SendMessageMarkdown(msg.Title, formattedContent, atList...) + default: + return SendError("未知的飞书发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/gotify.go b/internal/sdk/messenger/channels/gotify.go new file mode 100644 index 0000000..7da4d11 --- /dev/null +++ b/internal/sdk/messenger/channels/gotify.go @@ -0,0 +1,46 @@ +package channels + +import ( + "github.com/engigu/taskpool/internal/sdk/message" + "strconv" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type GotifyChannel struct{ *BaseChannel } + +func NewGotifyChannel() Channel { + return &GotifyChannel{NewBaseChannel(ChannelGotify, []string{FormatTypeText})} +} + +func (c *GotifyChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + url := config.GetString("url") + token := config.GetString("token") + + if url == "" || token == "" { + return SendError("gotify config missing: url and token are required"), nil + } + + priority, _ := strconv.Atoi(config.GetString("priority")) + cli := message.Gotify{ + Url: url, + Token: token, + Priority: priority, + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/helpers.go b/internal/sdk/messenger/channels/helpers.go new file mode 100644 index 0000000..065396c --- /dev/null +++ b/internal/sdk/messenger/channels/helpers.go @@ -0,0 +1,77 @@ +package channels + +import ( + "encoding/json" + "fmt" + "strings" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" + dysmsapi "github.com/alibabacloud-go/dysmsapi-20170525/v4/client" + "github.com/alibabacloud-go/tea/tea" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +// replaceBodyPlaceholder 替换自定义 webhook body 中的 TEXT 占位符 +func replaceBodyPlaceholder(body string, content string) string { + data, _ := json.Marshal(content) + dataStr := strings.Trim(string(data), "\"") + return strings.Replace(body, "TEXT", dataStr, -1) +} + +// createAliyunSMSClient 创建阿里云短信客户端 (V2.0) +func createAliyunSMSClient(accessKeyId, accessKeySecret, regionId string) (*dysmsapi.Client, error) { + config := &openapi.Config{ + AccessKeyId: tea.String(accessKeyId), + AccessKeySecret: tea.String(accessKeySecret), + RegionId: tea.String(regionId), + } + // 设置端点,通常为 dysmsapi.aliyuncs.com + config.Endpoint = tea.String("dysmsapi.aliyuncs.com") + return dysmsapi.NewClient(config) +} + +// sendAliyunSMS 发送短信 (V2.0) +func sendAliyunSMS(client *dysmsapi.Client, phoneNumber, signName, templateCode, content string, extra map[string]any) (string, error) { + templateParam := map[string]interface{}{ + "content": content, + } + for k, v := range extra { + templateParam[k] = v + } + templateParamJSON, _ := json.Marshal(templateParam) + + request := &dysmsapi.SendSmsRequest{ + PhoneNumbers: tea.String(phoneNumber), + SignName: tea.String(signName), + TemplateCode: tea.String(templateCode), + TemplateParam: tea.String(string(templateParamJSON)), + } + + response, err := client.SendSms(request) + if err != nil { + return "", fmt.Errorf("发送短信失败: %s", err.Error()) + } + + if response.Body == nil || tea.StringValue(response.Body.Code) != "OK" { + msg := "Unknown Error" + code := "Unknown Code" + if response.Body != nil { + msg = tea.StringValue(response.Body.Message) + code = tea.StringValue(response.Body.Code) + } + return "", fmt.Errorf("发送失败: %s - %s", code, msg) + } + + return fmt.Sprintf("RequestId: %s, BizId: %s", tea.StringValue(response.Body.RequestId), tea.StringValue(response.Body.BizId)), nil +} diff --git a/internal/sdk/messenger/channels/ntfy.go b/internal/sdk/messenger/channels/ntfy.go new file mode 100644 index 0000000..281962d --- /dev/null +++ b/internal/sdk/messenger/channels/ntfy.go @@ -0,0 +1,45 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type NtfyChannel struct{ *BaseChannel } + +func NewNtfyChannel() Channel { + return &NtfyChannel{NewBaseChannel(ChannelNtfy, []string{FormatTypeText})} +} + +func (c *NtfyChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + topic := config.GetString("topic") + if topic == "" { + return SendError("ntfy config missing: topic is required"), nil + } + + cli := message.Ntfy{ + Url: config.GetString("url"), + Topic: topic, + Priority: config.GetString("priority"), + Icon: config.GetString("icon"), + Token: config.GetString("token"), + Username: config.GetString("username"), + Password: config.GetString("password"), + Actions: config.GetString("actions"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/pushme.go b/internal/sdk/messenger/channels/pushme.go new file mode 100644 index 0000000..24aaea4 --- /dev/null +++ b/internal/sdk/messenger/channels/pushme.go @@ -0,0 +1,41 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type PushMeChannel struct{ *BaseChannel } + +func NewPushMeChannel() Channel { + return &PushMeChannel{NewBaseChannel(ChannelPushMe, []string{FormatTypeText})} +} + +func (c *PushMeChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + pushKey := config.GetString("push_key") + if pushKey == "" { + return SendError("pushme config missing: push_key is required"), nil + } + + cli := message.PushMe{ + PushKey: pushKey, + URL: config.GetString("url"), + Date: config.GetString("date"), + Type: config.GetString("type"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(res, err), nil + } + return SuccessResult(res), nil +} diff --git a/internal/sdk/messenger/channels/pushplus.go b/internal/sdk/messenger/channels/pushplus.go new file mode 100644 index 0000000..98a5e72 --- /dev/null +++ b/internal/sdk/messenger/channels/pushplus.go @@ -0,0 +1,44 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type PushPlusChannel struct{ *BaseChannel } + +func NewPushPlusChannel() Channel { + return &PushPlusChannel{NewBaseChannel(ChannelPushPlus, []string{FormatTypeText, FormatTypeHTML, FormatTypeMarkdown})} +} + +func (c *PushPlusChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + token := config.GetString("token") + if token == "" { + return SendError("pushplus config missing: token is required"), nil + } + + cli := message.PushPlus{ + Token: token, + Topic: config.GetString("topic"), + Template: config.GetString("template"), + Channel: config.GetString("channel"), + Webhook: config.GetString("webhook"), + CallbackUrl: config.GetString("callback_url"), + To: config.GetString("to"), + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/qyweixin.go b/internal/sdk/messenger/channels/qyweixin.go new file mode 100644 index 0000000..58b05f1 --- /dev/null +++ b/internal/sdk/messenger/channels/qyweixin.go @@ -0,0 +1,55 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type QyWeiXinChannel struct{ *BaseChannel } + +func NewQyWeiXinChannel() Channel { + return &QyWeiXinChannel{NewBaseChannel(ChannelQyWeiXin, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *QyWeiXinChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + accessToken := config.GetString("access_token") + + if accessToken == "" { + return SendError("qyweixin config missing: access_token is required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + atList := []string{} + atList = append(atList, msg.GetAtUserIds()...) + atList = append(atList, msg.GetAtMobiles()...) + if msg.AtAll { + atList = append(atList, "@all") + } + + cli := message.QyWeiXin{AccessToken: accessToken} + var res []byte + var err error + + switch contentType { + case FormatTypeText: + res, err = cli.SendMessageText(formattedContent, atList...) + case FormatTypeMarkdown: + res, err = cli.SendMessageMarkdown(msg.Title, formattedContent, atList...) + default: + return SendError("未知的企业微信发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/telegram.go b/internal/sdk/messenger/channels/telegram.go new file mode 100644 index 0000000..3b4d478 --- /dev/null +++ b/internal/sdk/messenger/channels/telegram.go @@ -0,0 +1,59 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type TelegramChannel struct{ *BaseChannel } + +func NewTelegramChannel() Channel { + return &TelegramChannel{NewBaseChannel(ChannelTelegram, []string{FormatTypeMarkdown, FormatTypeHTML, FormatTypeText})} +} + +func (c *TelegramChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + botToken := config.GetString("bot_token") + chatID := config.GetString("chat_id") + apiHost := config.GetString("api_host") + proxyURL := config.GetString("proxy_url") + + if botToken == "" || chatID == "" { + return SendError("telegram config missing: bot_token, chat_id are required"), nil + } + + contentType, formattedContent := c.FormatContent(msg) + cli := message.Telegram{ + BotToken: botToken, + ChatID: chatID, + ApiHost: apiHost, + ProxyURL: proxyURL, + } + + var res []byte + var err error + + switch contentType { + case FormatTypeText: + res, err = cli.SendMessageText(formattedContent) + case FormatTypeMarkdown: + res, err = cli.SendMessageMarkdown(formattedContent) + case FormatTypeHTML: + res, err = cli.SendMessageHTML(formattedContent) + default: + return SendError("未知的Telegram发送内容类型:%s", contentType), nil + } + + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/types.go b/internal/sdk/messenger/channels/types.go new file mode 100644 index 0000000..4bddf8b --- /dev/null +++ b/internal/sdk/messenger/channels/types.go @@ -0,0 +1,90 @@ +package channels + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +// Message 统一消息内容 +type Message struct { + Title string `json:"title"` + Text string `json:"text"` + HTML string `json:"html"` + Markdown string `json:"markdown"` + URL string `json:"url"` + ImageURL string `json:"image_url"` + Summary string `json:"summary"` + AtMobiles []string `json:"at_mobiles"` + AtUserIds []string `json:"at_user_ids"` + AtAll bool `json:"at_all"` + Extra map[string]any `json:"extra"` +} + +func (m *Message) HasText() bool { return m.Text != "" } +func (m *Message) HasHTML() bool { return m.HTML != "" } +func (m *Message) HasMarkdown() bool { return m.Markdown != "" } + +func (m *Message) GetAtMobiles() []string { + if m.AtMobiles == nil { + return []string{} + } + return m.AtMobiles +} + +func (m *Message) GetAtUserIds() []string { + if m.AtUserIds == nil { + return []string{} + } + return m.AtUserIds +} + +// ChannelConfig 渠道认证配置(Key-Value 形式,各渠道自行定义字段) +type ChannelConfig map[string]string + +// GetString 安全获取配置值 +func (c ChannelConfig) GetString(key string) string { + if v, ok := c[key]; ok { + return v + } + return "" +} + +// Result 发送结果 +type Result struct { + Success bool `json:"success"` + Response string `json:"response"` // 原始响应 + Error string `json:"error"` // 错误信息 +} + +// 消息格式类型常量 +const ( + FormatTypeText = "text" + FormatTypeHTML = "html" + FormatTypeMarkdown = "markdown" +) + +// 渠道类型常量 +const ( + ChannelEmail = "Email" + ChannelDtalk = "Dtalk" + ChannelQyWeiXin = "QyWeiXin" + ChannelFeishu = "Feishu" + ChannelCustom = "Custom" + ChannelWeChatOFAccount = "WeChatOFAccount" + ChannelAliyunSMS = "AliyunSMS" + ChannelTelegram = "Telegram" + ChannelBark = "Bark" + ChannelPushMe = "PushMe" + ChannelNtfy = "Ntfy" + ChannelGotify = "Gotify" + ChannelPushPlus = "PushPlus" + ChannelVoceChat = "VoceChat" + ChannelWxPusher = "WxPusher" +) diff --git a/internal/sdk/messenger/channels/vocechat.go b/internal/sdk/messenger/channels/vocechat.go new file mode 100644 index 0000000..2cfd40b --- /dev/null +++ b/internal/sdk/messenger/channels/vocechat.go @@ -0,0 +1,44 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type VoceChatChannel struct{ *BaseChannel } + +func NewVoceChatChannel() Channel { + return &VoceChatChannel{NewBaseChannel(ChannelVoceChat, []string{FormatTypeMarkdown})} +} + +func (c *VoceChatChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + server := config.GetString("server") + apiKey := config.GetString("api_key") + targetID := config.GetString("target_id") + + if server == "" || apiKey == "" || targetID == "" { + return SendError("vocechat config missing: server, api_key and target_id are required"), nil + } + + cli := message.VoceChat{ + Server: server, + APIKey: apiKey, + TargetType: config.GetString("target_type"), // defaults to "user" in SDK if not matched differently + TargetID: targetID, + } + + res, err := cli.Request(msg.Title, msg.Text) + if err != nil { + return ErrorResult(string(res), err), nil + } + return SuccessResult(string(res)), nil +} diff --git a/internal/sdk/messenger/channels/wechat.go b/internal/sdk/messenger/channels/wechat.go new file mode 100644 index 0000000..8fac9d5 --- /dev/null +++ b/internal/sdk/messenger/channels/wechat.go @@ -0,0 +1,50 @@ +package channels + +import "github.com/engigu/taskpool/internal/sdk/message" + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type WeChatOFAccountChannel struct{ *BaseChannel } + +func NewWeChatOFAccountChannel() Channel { + return &WeChatOFAccountChannel{NewBaseChannel(ChannelWeChatOFAccount, []string{FormatTypeText})} +} + +func (c *WeChatOFAccountChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + appID := config.GetString("appID") + appSecret := config.GetString("appsecret") + tempID := config.GetString("tempid") + toAccount := config.GetString("to_account") + + if appID == "" || appSecret == "" { + return SendError("wechat config missing: appID, appsecret are required"), nil + } + if toAccount == "" { + return SendError("wechat config missing: to_account is required"), nil + } + + _, formattedContent := c.FormatContent(msg) + cli := message.WeChatOFAccount{ + AppID: appID, + AppSecret: appSecret, + TemplateID: tempID, + ToUser: toAccount, + URL: msg.URL, + } + + res, err := cli.Send(msg.Title, formattedContent) + if err != nil { + return ErrorResult(res, err), nil + } + return SuccessResult(res), nil +} diff --git a/internal/sdk/messenger/channels/wxpusher.go b/internal/sdk/messenger/channels/wxpusher.go new file mode 100644 index 0000000..dcb4073 --- /dev/null +++ b/internal/sdk/messenger/channels/wxpusher.go @@ -0,0 +1,90 @@ +package channels + +import ( + "fmt" + "github.com/engigu/taskpool/internal/sdk/message" + "strconv" + "strings" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +type WxPusherChannel struct{ *BaseChannel } + +func NewWxPusherChannel() Channel { + return &WxPusherChannel{NewBaseChannel(ChannelWxPusher, []string{FormatTypeText})} +} + +func (c *WxPusherChannel) Send(config ChannelConfig, msg *Message) (*Result, error) { + appToken := config.GetString("app_token") + if appToken == "" { + return SendError("wxpusher config missing: app_token is required"), nil + } + + uidsStr := config.GetString("uids") + topicIdsStr := config.GetString("topic_ids") + verifyPayTypeStr := config.GetString("verify_pay_type") + + if uidsStr == "" && topicIdsStr == "" { + return SendError("wxpusher config missing: uids or topic_ids is required"), nil + } + + var uids []string + if uidsStr != "" { + uids = strings.Split(uidsStr, ",") + for i := range uids { + uids[i] = strings.TrimSpace(uids[i]) + } + } + + var topicIds []int + if topicIdsStr != "" { + ids := strings.Split(topicIdsStr, ",") + for _, idStr := range ids { + idStr = strings.TrimSpace(idStr) + if id, err := strconv.Atoi(idStr); err == nil { + topicIds = append(topicIds, id) + } + } + } + + verifyPayType := 0 + if verifyPayTypeStr != "" { + if v, err := strconv.Atoi(verifyPayTypeStr); err == nil { + verifyPayType = v + } + } + + _, formattedContent := c.FormatContent(msg) + + // 如果有标题,将标题和内容合并 + content := formattedContent + if msg.Title != "" { + content = fmt.Sprintf("%s\n\n%s", msg.Title, formattedContent) + } + + cli := message.WxPusher{ + AppToken: appToken, + Content: content, + ContentType: 1, // 仅支持文字 + Uids: uids, + TopicIds: topicIds, + VerifyPayType: verifyPayType, + } + + res, err := cli.Send() + if err != nil { + return ErrorResult(res, err), nil + } + return SuccessResult(res), nil +} diff --git a/internal/sdk/messenger/messenger.go b/internal/sdk/messenger/messenger.go new file mode 100644 index 0000000..e8b4a17 --- /dev/null +++ b/internal/sdk/messenger/messenger.go @@ -0,0 +1,213 @@ +// Package messenger 提供统一的消息发送SDK +// +// 此包可独立于 Message-Push-Nest 的业务层(数据库、HTTP路由等)使用 +// 适合在其他服务中直接引入来发送消息 +// +// 快速使用: +// +// result, err := messenger.Send("Telegram", messenger.ChannelConfig{ +// "bot_token": "your-bot-token", +// "chat_id": "your-chat-id", +// }, &messenger.Message{ +// Title: "Hello", +// Text: "World", +// }) +package messenger + +import ( + "fmt" + "github.com/engigu/taskpool/internal/sdk/messenger/channels" + "sync" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + + +// 重导出 channels 包的类型,方便外部使用 +type ( + Channel = channels.Channel + Message = channels.Message + ChannelConfig = channels.ChannelConfig + Result = channels.Result + BaseChannel = channels.BaseChannel +) + +// 重导出常量 +const ( + FormatTypeText = channels.FormatTypeText + FormatTypeHTML = channels.FormatTypeHTML + FormatTypeMarkdown = channels.FormatTypeMarkdown + + ChannelEmail = channels.ChannelEmail + ChannelDtalk = channels.ChannelDtalk + ChannelQyWeiXin = channels.ChannelQyWeiXin + ChannelFeishu = channels.ChannelFeishu + ChannelCustom = channels.ChannelCustom + ChannelWeChatOFAccount = channels.ChannelWeChatOFAccount + ChannelAliyunSMS = channels.ChannelAliyunSMS + ChannelTelegram = channels.ChannelTelegram + ChannelBark = channels.ChannelBark + ChannelPushMe = channels.ChannelPushMe + ChannelNtfy = channels.ChannelNtfy + ChannelGotify = channels.ChannelGotify + ChannelPushPlus = channels.ChannelPushPlus + ChannelVoceChat = channels.ChannelVoceChat + ChannelWxPusher = channels.ChannelWxPusher +) + +// 重导出辅助函数 +var ( + SuccessResult = channels.SuccessResult + ErrorResult = channels.ErrorResult + ErrorResultStr = channels.ErrorResultStr + SendError = channels.SendError + NewBaseChannel = channels.NewBaseChannel +) + +// channelFactory 渠道工厂注册表 +var ( + channelFactories = map[string]func() Channel{} + factoryMu sync.RWMutex +) + +func init() { + // 注册所有内置渠道 + RegisterChannel(ChannelEmail, func() Channel { return channels.NewEmailChannel() }) + RegisterChannel(ChannelDtalk, func() Channel { return channels.NewDtalkChannel() }) + RegisterChannel(ChannelQyWeiXin, func() Channel { return channels.NewQyWeiXinChannel() }) + RegisterChannel(ChannelFeishu, func() Channel { return channels.NewFeishuChannel() }) + RegisterChannel(ChannelTelegram, func() Channel { return channels.NewTelegramChannel() }) + RegisterChannel(ChannelBark, func() Channel { return channels.NewBarkChannel() }) + RegisterChannel(ChannelNtfy, func() Channel { return channels.NewNtfyChannel() }) + RegisterChannel(ChannelGotify, func() Channel { return channels.NewGotifyChannel() }) + RegisterChannel(ChannelPushMe, func() Channel { return channels.NewPushMeChannel() }) + RegisterChannel(ChannelCustom, func() Channel { return channels.NewCustomChannel() }) + RegisterChannel(ChannelWeChatOFAccount, func() Channel { return channels.NewWeChatOFAccountChannel() }) + RegisterChannel(ChannelAliyunSMS, func() Channel { return channels.NewAliyunSMSChannel() }) + RegisterChannel(ChannelPushPlus, func() Channel { return channels.NewPushPlusChannel() }) + RegisterChannel(ChannelVoceChat, func() Channel { return channels.NewVoceChatChannel() }) + RegisterChannel(ChannelWxPusher, func() Channel { return channels.NewWxPusherChannel() }) +} + +// RegisterChannel 注册自定义渠道(可用于扩展) +func RegisterChannel(channelType string, factory func() Channel) { + factoryMu.Lock() + defer factoryMu.Unlock() + channelFactories[channelType] = factory +} + +// GetChannel 获取渠道实例 +func GetChannel(channelType string) (Channel, error) { + factoryMu.RLock() + defer factoryMu.RUnlock() + factory, ok := channelFactories[channelType] + if !ok { + return nil, fmt.Errorf("未知的渠道类型: %s", channelType) + } + return factory(), nil +} + +// ListChannels 列出所有已注册的渠道类型 +func ListChannels() []string { + factoryMu.RLock() + defer factoryMu.RUnlock() + types := make([]string, 0, len(channelFactories)) + for t := range channelFactories { + types = append(types, t) + } + return types +} + +// Send 发送消息的便捷函数 +// +// 参数: +// - channelType: 渠道类型(如 "Telegram", "Dtalk" 等) +// - config: 渠道必要的认证配置 +// - msg: 消息内容 +// +// 使用示例: +// +// result, err := messenger.Send("Ntfy", messenger.ChannelConfig{ +// "topic": "my-topic", +// }, &messenger.Message{ +// Title: "Alert", +// Text: "Something happened!", +// }) +func Send(channelType string, config ChannelConfig, msg *Message) (*Result, error) { + ch, err := GetChannel(channelType) + if err != nil { + return nil, err + } + return ch.Send(config, msg) +} + +// Client 消息发送客户端(支持预设默认配置) +type Client struct { + defaultConfigs map[string]ChannelConfig + mu sync.RWMutex +} + +// NewClient 创建消息发送客户端 +func NewClient() *Client { + return &Client{ + defaultConfigs: make(map[string]ChannelConfig), + } +} + +// SetDefaultConfig 为指定渠道设置默认配置 +// +// 使用示例: +// +// client := messenger.NewClient() +// client.SetDefaultConfig("Telegram", messenger.ChannelConfig{ +// "bot_token": "default-token", +// "chat_id": "default-chat", +// }) +// // 后续发送时不需要再传 config 的相关字段 +// result, err := client.Send("Telegram", nil, &messenger.Message{...}) +func (c *Client) SetDefaultConfig(channelType string, config ChannelConfig) { + c.mu.Lock() + defer c.mu.Unlock() + c.defaultConfigs[channelType] = config +} + +// Send 使用客户端发送消息(会合并默认配置) +func (c *Client) Send(channelType string, config ChannelConfig, msg *Message) (*Result, error) { + mergedConfig := c.mergeConfig(channelType, config) + return Send(channelType, mergedConfig, msg) +} + +func (c *Client) mergeConfig(channelType string, config ChannelConfig) ChannelConfig { + c.mu.RLock() + defer c.mu.RUnlock() + + defaultConfig, hasDefault := c.defaultConfigs[channelType] + if !hasDefault && config == nil { + return ChannelConfig{} + } + if !hasDefault { + return config + } + if config == nil { + return defaultConfig + } + + // 合并:config 覆盖 defaultConfig + merged := make(ChannelConfig, len(defaultConfig)+len(config)) + for k, v := range defaultConfig { + merged[k] = v + } + for k, v := range config { + merged[k] = v + } + return merged +} diff --git a/internal/services/agent_service.go b/internal/services/agent_service.go new file mode 100644 index 0000000..dc7e80f --- /dev/null +++ b/internal/services/agent_service.go @@ -0,0 +1,565 @@ +package services + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/relation" + "github.com/engigu/taskpool/internal/services/tasks" + "github.com/engigu/taskpool/internal/utils" + + "gorm.io/gorm" +) + +// AgentService Agent 服务 +type AgentService struct{} + +// NewAgentService 创建 Agent 服务 +func NewAgentService() *AgentService { + return &AgentService{} +} + +// generateToken 生成随机 Token(64位十六进制) +func generateToken() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// ========== 令牌管理 ========== + +// CreateToken 创建令牌 +func (s *AgentService) CreateToken(remark string, maxUses int, expiresAt *time.Time) (*models.AgentToken, error) { + var expires *models.LocalTime + if expiresAt != nil { + t := models.LocalTime(*expiresAt) + expires = &t + } + + token := generateToken() + + agentToken := &models.AgentToken{ + ID: utils.GenerateID(), + Token: token, + Remark: remark, + MaxUses: maxUses, + ExpiresAt: expires, + Enabled: utils.BoolPtr(true), + } + + if err := database.DB.Create(agentToken).Error; err != nil { + return nil, err + } + + logger.Infof("[Agent] 创建令牌: %s (max_uses=%d)", token[:8]+"...", maxUses) + return agentToken, nil +} + +// ListTokens 获取令牌列表 +func (s *AgentService) ListTokens() []models.AgentToken { + var tokens []models.AgentToken + database.DB.Order("id DESC").Find(&tokens) + return tokens +} + +// DeleteToken 删除令牌 +func (s *AgentService) DeleteToken(id string) error { + return database.DB.Where("id = ?", id).Delete(&models.AgentToken{}).Error +} + +// ValidateToken 验证令牌 +func (s *AgentService) ValidateToken(token string) (*models.AgentToken, error) { + var agentToken models.AgentToken + res := database.DB.Where("token = ?", token).Limit(1).Find(&agentToken) + if res.Error != nil || res.RowsAffected == 0 { + return nil, &ServiceError{Message: "无效的令牌"} + } + + if !utils.DerefBool(agentToken.Enabled, true) { + return nil, &ServiceError{Message: "令牌已禁用"} + } + + // 检查使用次数 + if agentToken.MaxUses > 0 && agentToken.UsedCount >= agentToken.MaxUses { + return nil, &ServiceError{Message: "令牌已达到使用上限"} + } + + // 检查过期时间 + if agentToken.ExpiresAt != nil && time.Time(*agentToken.ExpiresAt).Before(time.Now()) { + return nil, &ServiceError{Message: "令牌已过期"} + } + + return &agentToken, nil +} + +// UseToken 使用令牌(增加使用计数) +func (s *AgentService) UseToken(id string) { + database.DB.Model(&models.AgentToken{}).Where("id = ?", id).UpdateColumn("used_count", gorm.Expr("used_count + 1")) +} + +// ========== Agent 注册 ========== + +// RegisterByToken 通过令牌注册 Agent(首次 WebSocket 连接时调用) +// 返回: agent, isNewAgent, error +func (s *AgentService) RegisterByToken(token string, machineID string, ip string) (*models.Agent, bool, error) { + // 验证令牌 + agentToken, err := s.ValidateToken(token) + if err != nil { + return nil, false, err + } + + // 如果提供了 machine_id,先检查是否已存在 + if machineID != "" { + var existing models.Agent + res := database.DB.Where("machine_id = ?", machineID).Limit(1).Find(&existing) + if res.Error == nil && res.RowsAffected > 0 { + // 已存在,更新 token 和状态,复用已有 Agent + now := models.LocalTime(time.Now()) + database.DB.Model(&existing).Updates(map[string]interface{}{ + "token": token, + "ip": ip, + "status": constant.AgentStatusOnline, + "last_seen": now, + }) + s.UseToken(agentToken.ID) + logger.Infof("[Agent] Agent #%s 通过 machine_id 复用 (%s)", existing.ID, machineID[:8]+"...") + return &existing, false, nil + } + } + + // 创建 Agent,使用令牌作为认证 Token + now := models.LocalTime(time.Now()) + agent := &models.Agent{ + ID: utils.GenerateID(), + Name: fmt.Sprintf("agent-%d", time.Now().Unix()), + Token: token, + MachineID: machineID, + IP: ip, + Status: constant.AgentStatusOnline, + LastSeen: &now, + Enabled: utils.BoolPtr(true), + } + + if err := database.DB.Create(agent).Error; err != nil { + return nil, false, err + } + + s.UseToken(agentToken.ID) + logger.Infof("[Agent] Agent 通过令牌注册: #%s (%s)", agent.ID, ip) + return agent, true, nil +} + +// Register Agent 注册(必须使用令牌)- 保留兼容旧版本 +func (s *AgentService) Register(req *models.AgentRegisterRequest, ip string) (*models.Agent, string, error) { + // 必须提供令牌 + if req.Token == "" { + return nil, "", &ServiceError{Message: "缺少令牌"} + } + + agentToken, err := s.ValidateToken(req.Token) + if err != nil { + return nil, "", err + } + + // 检查是否已存在同名 Agent + var existing models.Agent + res := database.DB.Where("name = ?", req.Name).Limit(1).Find(&existing) + if res.Error == nil && res.RowsAffected > 0 { + return nil, "", &ServiceError{Message: "Agent 名称已存在"} + } + + // 创建新 Agent,使用令牌作为认证 Token + now := models.LocalTime(time.Now()) + agent := &models.Agent{ + ID: utils.GenerateID(), + Name: req.Name, + Token: req.Token, + Hostname: req.Hostname, + Version: req.Version, + BuildTime: req.BuildTime, + IP: ip, + Status: constant.AgentStatusOnline, + LastSeen: &now, + Enabled: utils.BoolPtr(true), + } + + if err := database.DB.Create(agent).Error; err != nil { + return nil, "", err + } + + s.UseToken(agentToken.ID) + logger.Infof("[Agent] Agent 注册成功: %s (%s)", req.Name, ip) + return agent, req.Token, nil +} + +// Update 更新 Agent +func (s *AgentService) Update(id string, name, description string, enabled bool, schedulerConfig models.AgentSchedulerConfig) error { + return database.DB.Model(&models.Agent{}).Where("id = ?", id).Updates(map[string]interface{}{ + "name": name, + "description": description, + "enabled": &enabled, + "scheduler_config": schedulerConfig, + }).Error +} + +// Delete 删除 Agent(物理删除) +func (s *AgentService) Delete(id string) error { + // 检查是否有关联任务 + var count int64 + database.DB.Model(&models.Task{}).Where("agent_id = ?", id).Count(&count) + if count > 0 { + return &ServiceError{Message: "该 Agent 下还有关联任务,无法删除"} + } + + return database.DB.Where("id = ?", id).Delete(&models.Agent{}).Error +} + +// GetByID 根据 ID 获取 Agent +func (s *AgentService) GetByID(id string) *models.Agent { + var agent models.Agent + res := database.DB.Where("id = ?", id).Limit(1).Find(&agent) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + return &agent +} + +// GetByToken 根据 Token 获取 Agent +func (s *AgentService) GetByToken(token string) *models.Agent { + var agent models.Agent + res := database.DB.Where("token = ?", token).Limit(1).Find(&agent) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + return &agent +} + +// GetByMachineID 根据 MachineID 获取 Agent +func (s *AgentService) GetByMachineID(machineID string) *models.Agent { + var agent models.Agent + res := database.DB.Where("machine_id = ?", machineID).Limit(1).Find(&agent) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + return &agent +} + +// List 获取 Agent 列表 +func (s *AgentService) List() []models.Agent { + var agents []models.Agent + database.DB.Order("id DESC").Find(&agents) + return agents +} + +// RegenerateToken 重新生成 Token - 已废弃,保留空实现避免路由错误 +func (s *AgentService) RegenerateToken(id string) (string, error) { + return "", &ServiceError{Message: "此功能已禁用"} +} + +// Heartbeat Agent 心跳 +func (s *AgentService) Heartbeat(token, ip, version, buildTime, hostname, osType, arch string) (*models.Agent, error) { + agent := s.GetByToken(token) + if agent == nil { + return nil, &ServiceError{Message: "无效的 Token"} + } + + if !utils.DerefBool(agent.Enabled, true) { + return nil, &ServiceError{Message: "Agent 已禁用"} + } + + now := models.LocalTime(time.Now()) + updates := map[string]interface{}{ + "status": "online", + "last_seen": now, + "ip": ip, + } + if version != "" { + updates["version"] = version + } + if buildTime != "" { + updates["build_time"] = buildTime + } + if hostname != "" { + updates["hostname"] = hostname + } + if osType != "" { + updates["os"] = osType + } + if arch != "" { + updates["arch"] = arch + } + + database.DB.Model(&models.Agent{}).Where("id = ?", agent.ID).Updates(updates) + + agent.Status = constant.AgentStatusOnline + agent.LastSeen = &now + agent.IP = ip + agent.Version = version + agent.BuildTime = buildTime + agent.Hostname = hostname + agent.OS = osType + agent.Arch = arch + + return agent, nil +} + +// GetTasks 获取 Agent 的任务列表 +func (s *AgentService) GetTasks(agentID string) []models.AgentTask { + var tasksList []models.Task + database.DB.Where("agent_id = ? AND enabled = ?", agentID, true).Find(&tasksList) + + // 装载关联的变量信息 + if len(tasksList) > 0 { + taskIDs := make([]string, len(tasksList)) + for i, t := range tasksList { + taskIDs[i] = t.ID + } + envsMap := relation.DataRelation.LoadRelations(taskIDs, constant.RelationTypeTaskEnv) + for i, t := range tasksList { + if envs, ok := envsMap[t.ID]; ok { + tasksList[i].Envs = models.BigText(strings.Join(envs, ",")) + } + } + } + + result := make([]models.AgentTask, len(tasksList)) + envService := NewEnvService() + + for i, task := range tasksList { + // 加载环境配置 + var envVars []string + + // 检查全量注入模式 + allEnvs := false + if task.Config != "" { + var config models.TaskConfig + if err := json.Unmarshal([]byte(task.Config), &config); err == nil { + if config.AllEnvs { + allEnvs = true + } + } + } + + var secrets []string + if allEnvs { + envVars, secrets = envService.GetAllEnvVarsAndSecrets() + } else if string(task.Envs) != "" { + envVars, secrets = envService.GetEnvVarsAndSecretsByIDs(string(task.Envs)) + } + + envVarsStr := executor.FormatEnvVars(envVars) + + command := string(task.Command) + preCommand := string(task.PreCommand) + postCommand := string(task.PostCommand) + workDir := task.WorkDir + + // 仓库同步任务特殊处理:将配置转换为 reposync 命令行 + if task.Type == constant.TaskTypeRepo { + command, workDir = tasks.BuildRepoCommand(&task) + // 仓库任务的前置/后置命令已作为参数传给 reposync 内部处理,此处清空防止重复执行 + preCommand = "" + postCommand = "" + } + + result[i] = models.AgentTask{ + ID: task.ID, + Name: task.Name, + Command: command, + PreCommand: preCommand, + PostCommand: postCommand, + Schedule: task.Schedule, + Timeout: task.Timeout, + WorkDir: workDir, + Envs: envVarsStr, + Languages: []map[string]string(task.Languages), + RandomRange: task.RandomRange, + Secrets: secrets, + Enabled: utils.DerefBool(task.Enabled, true), + } + } + + return result +} + +// ReportResult Agent 上报执行结果 +func (s *AgentService) ReportResult(result *models.AgentTaskResult) error { + // 获取依赖的服务 + agentWSManager := GetAgentWSManager() + + // 先尝试通知正在等待的 goroutine + if agentWSManager.NotifyRemoteResult(result) { + logger.Infof("[Agent] 已通知正在等待任务 #%s 结果的 goroutine", result.TaskID) + return nil + } + + // 如果没有人在等待(例如服务重启后),则由本协程负责处理结果入库 + // 如果没有人在等待(例如服务重启后),则由本协程负责处理结果入库(记录日志并清理) + logger.Infof("[Agent] 没有找到等待任务 #%s 结果的 goroutine,直接处理结果", result.TaskID) + sendStatsService := NewSendStatsService() + taskLogService := tasks.NewTaskLogService(sendStatsService) + + // 创建日志对象前进行指令脱敏 + result.Command = utils.MaskSecrets(result.Command, utils.GetSystemSecrets()) + taskLog, err := taskLogService.CreateTaskLogFromAgentResult(result) + if err != nil { + return err + } + // 处理完成逻辑(保存日志、更新统计、清理旧日志等) + return taskLogService.ProcessTaskCompletion(taskLog) +} + +// UpdateTaskDuration 更新任务耗时(心跳) +func (s *AgentService) UpdateTaskDuration(logID string, duration int64) error { + taskLogService := tasks.NewTaskLogService(nil) + return taskLogService.UpdateTaskDuration(logID, duration) +} + +// UpdateOfflineAgents 更新离线 Agent 状态(超过 2 分钟无心跳) +func (s *AgentService) UpdateOfflineAgents() { + cutoff := time.Now().Add(-2 * time.Minute) + database.DB.Model(&models.Agent{}). + Where("status = ? AND last_seen < ?", constant.AgentStatusOnline, cutoff). + Update("status", constant.AgentStatusOffline) +} + +// ResetAllAgentsToOffline 将所有 Agents 状态重置为离线(用于服务启动时) +func (s *AgentService) ResetAllAgentsToOffline() { + database.DB.Model(&models.Agent{}). + Where("status = ?", constant.AgentStatusOnline). + Update("status", constant.AgentStatusOffline) +} + +// GetLatestVersion 获取最新 Agent 版本 +func (s *AgentService) GetLatestVersion() string { + // 优先从 /opt/agent 读取(容器内) + versionFile := "/opt/agent/version.txt" + data, err := os.ReadFile(versionFile) + if err != nil { + // 回退到 data/agent(本地开发) + data, err = os.ReadFile("data/agent/version.txt") + if err != nil { + return "" + } + } + return strings.TrimSpace(string(data)) +} + +// GetLatestBuildTime 获取最新 Agent 构建时间 +func (s *AgentService) GetLatestBuildTime() string { + return constant.BuildTime +} + +// CheckNeedUpdate 检查 Agent 是否需要更新 +// 比较 version 和 build_time,任一不同则需要更新 +func (s *AgentService) CheckNeedUpdate(agentVersion, agentBuildTime string) bool { + latestVersion := s.GetLatestVersion() + latestBuildTime := s.GetLatestBuildTime() + + // 如果没有最新版本信息,不需要更新 + if latestVersion == "" { + return false + } + + // 如果 Agent 没有版本信息,需要更新 + if agentVersion == "" { + return true + } + + // 版本不同,需要更新 + if agentVersion != latestVersion { + return true + } + + // 版本相同但构建时间不同,也需要更新 + if latestBuildTime != "" && latestBuildTime != "unknown" && agentBuildTime != "" && agentBuildTime != latestBuildTime { + return true + } + + return false +} + +// GetAvailablePlatforms 获取可用的平台列表 +func (s *AgentService) GetAvailablePlatforms() []map[string]string { + platforms := []map[string]string{} + + // 优先从 /opt/agent 读取(容器内) + agentDir := "/opt/agent" + files, err := os.ReadDir(agentDir) + if err != nil { + // 回退到 data/agent(本地开发) + agentDir = "data/agent" + files, err = os.ReadDir(agentDir) + if err != nil { + return platforms + } + } + + for _, f := range files { + name := f.Name() + // taskpool-agent-linux-amd64.tar.gz + if strings.HasPrefix(name, "taskpool-agent-") && strings.HasSuffix(name, ".tar.gz") { + // 去掉 .tar.gz 后缀 + baseName := strings.TrimSuffix(name, ".tar.gz") + parts := strings.Split(baseName, "-") + if len(parts) >= 4 { + platforms = append(platforms, map[string]string{ + "os": parts[2], + "arch": parts[3], + "filename": name, + }) + } + } + } + + return platforms +} + +// GetAgentBinary 获取 Agent 压缩包 +func (s *AgentService) GetAgentBinary(osType, arch string) ([]byte, string, error) { + filename := fmt.Sprintf("taskpool-agent-%s-%s.tar.gz", osType, arch) + + // 优先从 /opt/agent 读取(容器内) + filePath := filepath.Join("/opt/agent", filename) + data, err := os.ReadFile(filePath) + if err != nil { + // 回退到 data/agent(本地开发) + filePath = filepath.Join("data/agent", filename) + data, err = os.ReadFile(filePath) + if err != nil { + return nil, "", &ServiceError{Message: "未找到对应平台的 Agent 程序"} + } + } + + return data, filename, nil +} + +// SetForceUpdate 设置强制更新标志 +func (s *AgentService) SetForceUpdate(id string) error { + return database.DB.Model(&models.Agent{}).Where("id = ?", id).Update("force_update", true).Error +} + +// ClearForceUpdate 清除强制更新标志 +func (s *AgentService) ClearForceUpdate(id string) error { + return database.DB.Model(&models.Agent{}).Where("id = ?", id).Update("force_update", false).Error +} + +// ServiceError 服务错误 +type ServiceError struct { + Message string +} + +func (e *ServiceError) Error() string { + return e.Message +} diff --git a/internal/services/agent_ws_service.go b/internal/services/agent_ws_service.go new file mode 100644 index 0000000..c262de6 --- /dev/null +++ b/internal/services/agent_ws_service.go @@ -0,0 +1,410 @@ +package services + +import ( + "encoding/json" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + + "github.com/gorilla/websocket" +) + +// AgentWSManager WebSocket 连接管理器 +type AgentWSManager struct { + connections map[string]*AgentConnection // Agent ID -> 连接对象 + ipConnections map[string]int // IP -> 连接数 + ipLastAttempt map[string]time.Time // IP -> 最后连接尝试时间 + ipFailCount map[string]int // IP -> 连续失败次数 + remoteWaiters map[string]chan *models.AgentTaskResult // 日志 ID -> 结果通道 + mu sync.RWMutex +} + +// 限流配置 +const ( + maxConnectionsPerIP = 10 // 每个 IP 最大连接数 + minConnectInterval = 5 * time.Second // 同一 IP 最小连接间隔 + maxFailCount = 5 // 最大连续失败次数 + failBlockDuration = 5 * time.Minute // 失败后封禁时长 +) + +// AgentConnection Agent WebSocket 连接 +type AgentConnection struct { + AgentID string + IP string + Conn *websocket.Conn + Send chan []byte + LastPing time.Time + closed bool + mu sync.Mutex +} + +// WSMessage WebSocket 消息结构 +type WSMessage struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` +} + +// 消息类型常量 +const ( + WSTypeHeartbeat = constant.WSTypeHeartbeat + WSTypeHeartbeatAck = constant.WSTypeHeartbeatAck + WSTypeTasks = constant.WSTypeTasks + WSTypeTaskResult = constant.WSTypeTaskResult + WSTypeUpdate = constant.WSTypeUpdate + WSTypeDisconnect = constant.WSTypeDisconnect + WSTypeConnected = constant.WSTypeConnected + WSTypeDisabled = constant.WSTypeDisabled + WSTypeEnabled = constant.WSTypeEnabled + WSTypeFetchTasks = constant.WSTypeFetchTasks + WSTypeTaskLog = constant.WSTypeTaskLog + WSTypeExecute = constant.WSTypeExecute + WSTypeTaskHeartbeat = constant.WSTypeTaskHeartbeat +) + +var agentWSManager *AgentWSManager +var agentWSOnce sync.Once + +// GetAgentWSManager 获取单例 +func GetAgentWSManager() *AgentWSManager { + agentWSOnce.Do(func() { + agentWSManager = &AgentWSManager{ + connections: make(map[string]*AgentConnection), + ipConnections: make(map[string]int), + ipLastAttempt: make(map[string]time.Time), + ipFailCount: make(map[string]int), + remoteWaiters: make(map[string]chan *models.AgentTaskResult), + } + // 启动时,先将所有 "online" 状态的 Agent 重置为 "offline" + NewAgentService().ResetAllAgentsToOffline() + + // 将清理任务注册到系统内部 Cron,每 30 秒执行一次 + executor.GetSysCron().AddJob("@every 30s", agentWSManager.cleanupLoop) + }) + return agentWSManager +} + +// CheckRateLimit 检查 IP 限流,返回是否允许连接 +func (m *AgentWSManager) CheckRateLimit(ip string) (bool, string) { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + + // 检查是否被封禁(连续失败过多) + if failCount, exists := m.ipFailCount[ip]; exists && failCount >= maxFailCount { + if lastAttempt, ok := m.ipLastAttempt[ip]; ok { + if now.Sub(lastAttempt) < failBlockDuration { + remaining := failBlockDuration - now.Sub(lastAttempt) + return false, "连接失败次数过多,请 " + remaining.Round(time.Second).String() + " 后重试" + } + // 封禁时间已过,重置计数 + delete(m.ipFailCount, ip) + } + } + + // 检查连接频率 + if lastAttempt, exists := m.ipLastAttempt[ip]; exists { + if now.Sub(lastAttempt) < minConnectInterval { + return false, "连接过于频繁,请稍后重试" + } + } + + // 检查 IP 连接数 + if count, exists := m.ipConnections[ip]; exists && count >= maxConnectionsPerIP { + return false, "该 IP 连接数已达上限" + } + + m.ipLastAttempt[ip] = now + return true, "" +} + +// RecordConnectFail 记录连接失败 +func (m *AgentWSManager) RecordConnectFail(ip string) { + m.mu.Lock() + defer m.mu.Unlock() + m.ipFailCount[ip]++ + m.ipLastAttempt[ip] = time.Now() + if m.ipFailCount[ip] >= maxFailCount { + logger.Warnf("[AgentWS] IP %s 连续失败 %d 次,已封禁 %v", ip, m.ipFailCount[ip], failBlockDuration) + } +} + +// RecordConnectSuccess 记录连接成功,重置失败计数 +func (m *AgentWSManager) RecordConnectSuccess(ip string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.ipFailCount, ip) +} + +// Register 注册连接 +func (m *AgentWSManager) Register(agentID string, conn *websocket.Conn, ip string) *AgentConnection { + m.mu.Lock() + defer m.mu.Unlock() + + // 关闭旧连接 + if old, exists := m.connections[agentID]; exists { + // 减少旧 IP 的连接计数 + if old.IP != "" { + if count, ok := m.ipConnections[old.IP]; ok && count > 0 { + m.ipConnections[old.IP] = count - 1 + } + } + old.Close() + } + + ac := &AgentConnection{ + AgentID: agentID, + IP: ip, + Conn: conn, + Send: make(chan []byte, 256), + LastPing: time.Now(), + } + m.connections[agentID] = ac + + // 增加 IP 连接计数 + m.ipConnections[ip]++ + + logger.Infof("[AgentWS] Agent #%s 已连接 (%s)", agentID, ip) + return ac +} + +// Unregister 注销连接(只注销指定的连接实例) +func (m *AgentWSManager) Unregister(agentID string, ac *AgentConnection) { + m.mu.Lock() + defer m.mu.Unlock() + + // 只有当前连接和 map 中的连接是同一个实例时才删除 + if conn, exists := m.connections[agentID]; exists && conn == ac { + // 减少 IP 连接计数 + if conn.IP != "" { + if count, ok := m.ipConnections[conn.IP]; ok && count > 0 { + m.ipConnections[conn.IP] = count - 1 + } + } + conn.Close() + delete(m.connections, agentID) + logger.Infof("[AgentWS] Agent #%s 已断开", agentID) + } +} + +// GetConnection 获取连接 +func (m *AgentWSManager) GetConnection(agentID string) *AgentConnection { + m.mu.RLock() + defer m.mu.RUnlock() + return m.connections[agentID] +} + +// IsAgentOnline 检查指定 Agent 是否在线 +func (m *AgentWSManager) IsAgentOnline(agentID string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + _, exists := m.connections[agentID] + return exists +} + +// SendToAgent 发送消息给指定 Agent +func (m *AgentWSManager) SendToAgent(agentID string, msgType string, data interface{}) error { + conn := m.GetConnection(agentID) + if conn == nil { + return nil // Agent 不在线 + } + + dataBytes, _ := json.Marshal(data) + msg := WSMessage{Type: msgType, Data: dataBytes} + msgBytes, _ := json.Marshal(msg) + + select { + case conn.Send <- msgBytes: + return nil + default: + return nil // 缓冲区满,丢弃 + } +} + +// BroadcastTasks 广播任务更新给指定 Agent +func (m *AgentWSManager) BroadcastTasks(agentID string) { + agentService := NewAgentService() + tasks := agentService.GetTasks(agentID) + m.SendToAgent(agentID, WSTypeTasks, map[string]interface{}{ + "tasks": tasks, + }) +} + +// BroadcastTasksToAll 广播任务更新给所有在线 Agent +func (m *AgentWSManager) BroadcastTasksToAll() { + m.mu.RLock() + var agentIDs []string + for agentID := range m.connections { + agentIDs = append(agentIDs, agentID) + } + m.mu.RUnlock() + + for _, agentID := range agentIDs { + m.BroadcastTasks(agentID) + } +} + +// RegisterRemoteWaiter 注册远程任务结果等待者 +func (m *AgentWSManager) RegisterRemoteWaiter(logID string) chan *models.AgentTaskResult { + m.mu.Lock() + defer m.mu.Unlock() + ch := make(chan *models.AgentTaskResult, 1) + m.remoteWaiters[logID] = ch + return ch +} + +// UnregisterRemoteWaiter 注销远程任务结果等待者 +func (m *AgentWSManager) UnregisterRemoteWaiter(logID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.remoteWaiters, logID) +} + +// NotifyRemoteResult 通知远程任务结果 +func (m *AgentWSManager) NotifyRemoteResult(result *models.AgentTaskResult) bool { + m.mu.RLock() + defer m.mu.RUnlock() + if ch, ok := m.remoteWaiters[result.LogID]; ok { + select { + case ch <- result: + return true + default: + return false + } + } + return false +} + +// OnlineCount 在线 Agent 数量 +func (m *AgentWSManager) OnlineCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.connections) +} + +// cleanupLoop 清理超时连接 (由 SysCron 每 30 秒调用一次) +func (m *AgentWSManager) cleanupLoop() { + defer func() { + if r := recover(); r != nil { + logger.Errorf("[AgentWS] cleanupLoop panic: %v", r) + } + }() + + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now() + + // 清理超时连接 + for agentID, conn := range m.connections { + if now.Sub(conn.LastPing) > 2*time.Minute { + // 减少 IP 连接计数 + if conn.IP != "" { + if count, ok := m.ipConnections[conn.IP]; ok && count > 0 { + m.ipConnections[conn.IP] = count - 1 + } + } + conn.Close() + delete(m.connections, agentID) + // 更新数据库状态 + database.DB.Model(&models.Agent{}).Where("id = ?", agentID).Update("status", constant.AgentStatusOffline) + logger.Infof("[AgentWS] Agent #%s 心跳超时,已断开", agentID) + } + } + + // 定期清理数据库中的过期状态(处理服务重启或异常终止的情况) + cutoff := now.Add(-2 * time.Minute) + database.DB.Model(&models.Agent{}). + Where("status = ? AND last_seen < ?", constant.AgentStatusOnline, cutoff). + Update("status", constant.AgentStatusOffline) + + // 清理过期的限流记录(超过 10 分钟未活动) + for ip, lastAttempt := range m.ipLastAttempt { + if now.Sub(lastAttempt) > 10*time.Minute { + delete(m.ipLastAttempt, ip) + delete(m.ipFailCount, ip) + // 只清理没有活跃连接的 IP 计数 + if m.ipConnections[ip] == 0 { + delete(m.ipConnections, ip) + } + } + } +} + +// Close 关闭连接 +func (c *AgentConnection) Close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return + } + c.closed = true + if c.Conn != nil { + c.Conn.Close() + } + if c.Send != nil { + close(c.Send) + } +} + +// IsClosed 检查连接是否已关闭 +func (c *AgentConnection) IsClosed() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.closed +} + +// WriteMessage 写入消息 +func (c *AgentConnection) WriteMessage(data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.Conn == nil { + return nil + } + c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + return c.Conn.WriteMessage(websocket.TextMessage, data) +} + +// SetReadDeadline 设置读取超时 +func (c *AgentConnection) SetReadDeadline(t time.Time) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.Conn == nil { + return nil + } + return c.Conn.SetReadDeadline(t) +} + +// ReadMessage 读取消息 +func (c *AgentConnection) ReadMessage() (int, []byte, error) { + // 不加锁,因为 ReadMessage 是阻塞的 + // 但需要先检查连接状态 + c.mu.Lock() + if c.closed || c.Conn == nil { + c.mu.Unlock() + return 0, nil, websocket.ErrCloseSent + } + conn := c.Conn + c.mu.Unlock() + return conn.ReadMessage() +} + +// WritePing 发送 ping 消息 +func (c *AgentConnection) WritePing() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.Conn == nil { + return nil + } + c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + return c.Conn.WriteMessage(websocket.PingMessage, nil) +} + +// UpdatePing 更新心跳时间 +func (c *AgentConnection) UpdatePing() { + c.LastPing = time.Now() +} diff --git a/internal/services/app_log_service.go b/internal/services/app_log_service.go new file mode 100644 index 0000000..2325578 --- /dev/null +++ b/internal/services/app_log_service.go @@ -0,0 +1,357 @@ +package services + +import ( + "time" + + "fmt" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +type LogRetentionConfig struct { + Days int `json:"days"` + MaxCount int `json:"max_count"` +} + +type AppLogService struct { + settingsService *SettingsService +} + +func NewAppLogService() *AppLogService { + return &AppLogService{ + settingsService: NewSettingsService(), + } +} + +func (s *AppLogService) Add(log *models.AppLog) error { + if log.ID == "" { + log.ID = utils.GenerateID() + } + err := database.DB.Create(log).Error + if err == nil { + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventAppLogAdded, + Payload: log, + }) + } + return err +} + +func (s *AppLogService) List(category string, status string, level string, page, pageSize int, keyword string) ([]models.AppLog, int64, error) { + var logs []models.AppLog + var total int64 + db := database.DB + + // 如果是推送日志,尝试关联查询渠道名称 + if category == constant.LogCategoryPushLog { + db = db.Table(models.AppLog{}.TableName() + " AS al"). + Select("al.*, nw.name as channel_name"). + Joins(fmt.Sprintf("LEFT JOIN %s AS nw ON al.ref_id = nw.id", models.NotifyWay{}.TableName())) + } else { + db = db.Model(&models.AppLog{}) + } + + if category != "" { + if category == constant.LogCategoryPushLog { + db = db.Where("al.category = ?", category) + } else { + db = db.Where("category = ?", category) + } + } + if status != "" { + field := "status" + if category == constant.LogCategoryPushLog { + field = "al.status" + } + db = db.Where(field+" = ?", status) + } + if level != "" { + field := "level" + if category == constant.LogCategoryPushLog { + field = "al.level" + } + db = db.Where(field+" = ?", level) + } + if keyword != "" { + if category == constant.LogCategoryPushLog { + db = db.Where("(al.title LIKE ? OR al.content LIKE ?)", "%"+keyword+"%", "%"+keyword+"%") + } else { + db = db.Where("(title LIKE ? OR content LIKE ?)", "%"+keyword+"%", "%"+keyword+"%") + } + } + + db.Count(&total) + offset := (page - 1) * pageSize + order := "created_at desc" + if category == constant.LogCategoryPushLog { + order = "al.created_at desc" + } + err := db.Order(order).Offset(offset).Limit(pageSize).Scan(&logs).Error + return logs, total, err +} + +func (s *AppLogService) MarkAsRead(id string) error { + now := models.LocalTime(time.Now()) + return database.DB.Model(&models.AppLog{}).Where("id = ?", id).Updates(map[string]interface{}{ + "status": constant.LogStatusRead, + "read_at": &now, + }).Error +} + +func (s *AppLogService) MarkAllAsRead(category string) error { + now := models.LocalTime(time.Now()) + return database.DB.Model(&models.AppLog{}).Where("category = ? AND status = ?", category, constant.LogStatusUnread).Updates(map[string]interface{}{ + "status": constant.LogStatusRead, + "read_at": &now, + }).Error +} + +func (s *AppLogService) Clear(category string) error { + query := database.DB.Model(&models.AppLog{}) + if category != "" { + query = query.Where("category = ?", category) + } + return query.Delete(&models.AppLog{}).Error +} + +func (s *AppLogService) GetRetentionConfigs() map[string]LogRetentionConfig { + configs := map[string]LogRetentionConfig{ + constant.LogCategorySystemNotice: { + Days: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeySystemNoticeDays), 30), + MaxCount: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeySystemNoticeMaxCount), 500), + }, + constant.LogCategoryPushLog: { + Days: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeyPushLogDays), 15), + MaxCount: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeyPushLogMaxCount), 5000), + }, + constant.LogCategoryLoginLog: { + Days: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeyLoginLogDays), 30), + MaxCount: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeyLoginLogMaxCount), 1000), + }, + constant.LogCategorySchedulerLog: { + Days: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeySchedulerLogDays), 30), + MaxCount: utils.ToInt(s.settingsService.Get(constant.SectionSystem, constant.KeySchedulerLogMaxCount), 10000), + }, + constant.LogCategoryDefault: { + Days: 30, + MaxCount: 10000, + }, + } + return configs +} + +func (s *AppLogService) AddSchedulerLog(title, content, level string) error { + if level == "" { + level = constant.LogLevelInfo + } + return s.Add(&models.AppLog{ + Category: constant.LogCategorySchedulerLog, + Title: title, + Content: models.BigText(content), + Level: level, + Status: constant.LogStatusRead, + }) +} + +func (s *AppLogService) CleanUp() { + configs := s.GetRetentionConfigs() + categories := []string{constant.LogCategorySystemNotice, constant.LogCategoryPushLog, constant.LogCategoryLoginLog, constant.LogCategorySchedulerLog} + + var totalDeleted int64 + var summaryBuilder strings.Builder + + for _, cat := range categories { + cfg, ok := configs[cat] + if !ok { + cfg = configs[constant.LogCategoryDefault] + } + + var daysDeleted int64 + if cfg.Days > 0 { + deadline := time.Now().AddDate(0, 0, -cfg.Days) + res := database.DB.Where("category = ? AND created_at < ?", cat, deadline).Delete(&models.AppLog{}) + if res.Error == nil { + daysDeleted = res.RowsAffected + } + } + + var countDeleted int64 + if cfg.MaxCount > 0 { + var total int64 + database.DB.Model(&models.AppLog{}).Where("category = ?", cat).Count(&total) + if total > int64(cfg.MaxCount) { + deleteCount := total - int64(cfg.MaxCount) + var ids []string + database.DB.Model(&models.AppLog{}).Where("category = ?", cat).Order("created_at asc").Limit(int(deleteCount)).Pluck("id", &ids) + if len(ids) > 0 { + res := database.DB.Where("id IN ?", ids).Delete(&models.AppLog{}) + if res.Error == nil { + countDeleted = res.RowsAffected + } + } + } + } + + catTotal := daysDeleted + countDeleted + if catTotal > 0 { + totalDeleted += catTotal + var catLabel string + switch cat { + case constant.LogCategorySystemNotice: + catLabel = "系统通知" + case constant.LogCategoryPushLog: + catLabel = "推送日志" + case constant.LogCategoryLoginLog: + catLabel = "登录日志" + case constant.LogCategorySchedulerLog: + catLabel = "调度日志" + default: + catLabel = cat + } + summaryBuilder.WriteString(fmt.Sprintf("分类 [%s]: 过期清理 %d 条,溢出限制清理 %d 条;\n", catLabel, daysDeleted, countDeleted)) + } + } + + if totalDeleted > 0 { + logger.Infof("[AppLog] 周期巡检完成清理,共删除 %d 条陈旧日志", totalDeleted) + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventSystemNotice, + Payload: map[string]interface{}{ + "title": "系统日志定时容量收敛", + "content": fmt.Sprintf("后台巡检已自动执行日志容量清理,共计清除陈旧或溢出记录 %d 条。\n详情明细:\n%s", totalDeleted, summaryBuilder.String()), + "level": constant.LogLevelInfo, + }, + }) + } else { + logger.Debugf("[AppLog] 完成应用日志清理策略,未检测到需要剔除的陈旧记录") + } +} + +func (s *AppLogService) SubscribeEvents(bus *eventbus.EventBus) { + // 1. [订阅] 系统通知 -> 存储到数据库表现为红点消息 + bus.Subscribe(constant.EventSystemNotice, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + + title, _ := payload["title"].(string) + content, _ := payload["content"].(string) + level, _ := payload["level"].(string) + if level == "" { + level = constant.LogLevelInfo + } + + s.Add(&models.AppLog{ + Category: constant.LogCategorySystemNotice, + Title: title, + Content: models.BigText(content), + Level: level, + Status: constant.LogStatusUnread, + }) + }) + + // 2. [订阅] 推送结果 -> 存储到数据库供推送日志查看 + bus.Subscribe(constant.EventNotifySent, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + + title, _ := payload["title"].(string) + content, _ := payload["content"].(string) + success, _ := payload["success"].(bool) + errorMsg, _ := payload["error_msg"].(string) + channelID, _ := payload["channel_id"].(string) + + status := constant.LogStatusSuccess + level := constant.LogLevelInfo + if !success { + status = constant.LogStatusFailed + level = constant.LogLevelError + } + + s.Add(&models.AppLog{ + Category: constant.LogCategoryPushLog, + Title: title, + Content: models.BigText(content), + Level: level, + Status: status, + RefID: channelID, + ErrorMsg: models.BigText(errorMsg), + }) + }) + + // 3. 将某些业务事件转化为系统内部通知 (自动出现在小铃铛) + /* + bus.Subscribe(constant.EventTaskFailed, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + taskName, _ := payload["task_name"].(string) + errMsg, _ := payload["error"].(string) + + bus.Publish(eventbus.Event{ + Type: constant.EventSystemNotice, + Payload: map[string]interface{}{ + "title": fmt.Sprintf("任务 [%s] 执行失败", taskName), + "content": fmt.Sprintf("错误详情: %s", errMsg), + "level": constant.LogLevelError, + }, + }) + }) + + bus.Subscribe(constant.EventTaskTimeout, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + taskName, _ := payload["task_name"].(string) + + bus.Publish(eventbus.Event{ + Type: constant.EventSystemNotice, + Payload: map[string]interface{}{ + "title": fmt.Sprintf("任务 [%s] 执行超时", taskName), + "content": "任务已经超过预设的运行时间并被系统强制中止。", + "level": constant.LogLevelWarning, + }, + }) + }) + */ + + bus.Subscribe(constant.EventPasswordChanged, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + username, _ := payload["username"].(string) + + bus.Publish(eventbus.Event{ + Type: constant.EventSystemNotice, + Payload: map[string]interface{}{ + "title": "账户安全提醒", + "content": fmt.Sprintf("用户 %s 的账号密码已被修改。", username), + "level": constant.LogLevelInfo, + }, + }) + }) + + // 4. [订阅] 调度日志写入 + bus.Subscribe(constant.EventSchedulerLog, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + title, _ := payload["title"].(string) + content, _ := payload["content"].(string) + level, _ := payload["level"].(string) + s.AddSchedulerLog(title, content, level) + }) +} diff --git a/internal/services/backup_service.go b/internal/services/backup_service.go new file mode 100644 index 0000000..d9d7f8c --- /dev/null +++ b/internal/services/backup_service.go @@ -0,0 +1,530 @@ +package services + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/engigu/taskpool/internal/cache" + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/systime" + "github.com/rs/xid" + "gorm.io/gorm" +) + +type BackupService struct { + settingsService *SettingsService +} + +func NewBackupService() *BackupService { + return &BackupService{ + settingsService: NewSettingsService(), + } +} + +const ( + BackupSection = "backup" + BackupFileKey = "backup_file" + BackupDir = "./data/backups" +) + +// tableConfig 表备份配置 +type tableConfig struct { + filename string + export func(io.Writer) error + restore func([]byte) error +} + +func (s *BackupService) getTableConfigs() []tableConfig { + return []tableConfig{ + {"users.json", s.exportTable(&[]models.User{}), s.restoreTable(&[]models.User{})}, + {"tasks.json", s.exportTable(&[]models.Task{}), s.restoreTable(&[]models.Task{})}, + {"task_logs.json", s.exportTable(&[]models.TaskLog{}), s.restoreTable(&[]models.TaskLog{})}, + {"envs.json", s.exportTable(&[]models.EnvironmentVariable{}), s.restoreTable(&[]models.EnvironmentVariable{})}, + {"scripts.json", s.exportTable(&[]models.Script{}), s.restoreTable(&[]models.Script{})}, + {"settings.json", s.exportSettings, s.restoreSettings}, + {"send_stats.json", s.exportTable(&[]models.SendStats{}), s.restoreTable(&[]models.SendStats{})}, + + {"agents.json", s.exportTable(&[]models.Agent{}), s.restoreTable(&[]models.Agent{})}, + {"tokens.json", s.exportTable(&[]models.AgentToken{}), s.restoreTable(&[]models.AgentToken{})}, + {"languages.json", s.exportTable(&[]models.Language{}), s.restoreTable(&[]models.Language{})}, + {"deps.json", s.exportTable(&[]models.Dependency{}), s.restoreTable(&[]models.Dependency{})}, + {"notify_ways.json", s.exportTable(&[]models.NotifyWay{}), s.restoreTable(&[]models.NotifyWay{})}, + {"notify_bindings.json", s.exportTable(&[]models.NotifyBinding{}), s.restoreTable(&[]models.NotifyBinding{})}, + {"app_logs.json", s.exportTable(&[]models.AppLog{}), s.restoreTable(&[]models.AppLog{})}, + {"data_storage.json", s.exportTable(&[]models.DataStorage{}), s.restoreTable(&[]models.DataStorage{})}, + {"data_relations.json", s.exportTable(&[]models.DataRelation{}), s.restoreTable(&[]models.DataRelation{})}, + } +} + +func (s *BackupService) exportTable(modelPtr any) func(io.Writer) error { + return func(w io.Writer) error { + db := database.DB + + if _, err := w.Write([]byte("[\n")); err != nil { + return err + } + + first := true + err := db.FindInBatches(modelPtr, 1000, func(tx *gorm.DB, batch int) error { + val := reflect.ValueOf(modelPtr).Elem() + count := val.Len() + for i := 0; i < count; i++ { + if !first { + if _, err := w.Write([]byte(",\n")); err != nil { + return err + } + } + item := val.Index(i).Interface() + jsonData, err := json.MarshalIndent(item, " ", " ") + if err != nil { + return err + } + if _, err := w.Write(jsonData); err != nil { + return err + } + first = false + } + return nil + }).Error + + if err != nil { + return err + } + + _, err = w.Write([]byte("\n]")) + return err + } +} + +func (s *BackupService) restoreTable(dest any) func([]byte) error { + return func(data []byte) error { + if err := json.Unmarshal(data, dest); err != nil { + return err + } + return nil + } +} + +func (s *BackupService) exportSettings(w io.Writer) error { + var data []models.Setting + if err := database.DB.Where("section != ?", BackupSection).Find(&data).Error; err != nil { + return err + } + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + _, err = w.Write(jsonData) + return err +} + +func (s *BackupService) restoreSettings(data []byte) error { + var settings []models.Setting + return json.Unmarshal(data, &settings) +} + +// CreateBackup 创建备份 +func (s *BackupService) CreateBackup() (string, error) { + if err := os.MkdirAll(BackupDir, 0755); err != nil { + return "", err + } + + timestamp := systime.FormatDatetime(time.Now()) + zipPath := filepath.Join(BackupDir, fmt.Sprintf("backup_%s.zip", timestamp)) + + zipFile, err := os.Create(zipPath) + if err != nil { + return "", err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // 导出各表 + for _, cfg := range s.getTableConfigs() { + w, err := zipWriter.Create(cfg.filename) + if err != nil { + return "", err + } + if err := cfg.export(w); err != nil { + return "", err + } + } + + // 写入元数据信息 + sysInfo := map[string]interface{}{ + "version": "v3", + "ts": time.Now().Format("2006-01-02 15:04:05"), + } + sysFile, err := zipWriter.Create("__sys__.json") + if err != nil { + return "", err + } + sysData, _ := json.MarshalIndent(sysInfo, "", " ") + if _, err := sysFile.Write(sysData); err != nil { + return "", err + } + + // 打包 scripts 文件夹 + scriptsDir := constant.ScriptsWorkDir + if _, err := os.Stat(scriptsDir); err == nil { + if err := s.addDirToZip(zipWriter, scriptsDir, "scripts"); err != nil { + return "", err + } + } + + s.settingsService.Set(BackupSection, BackupFileKey, zipPath) + return zipPath, nil +} + +// Restore 恢复备份 +func (s *BackupService) Restore(zipPath string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + // 构建文件名到配置的映射 + configs := s.getTableConfigs() + fileMap := make(map[string]*zip.File) + for _, f := range r.File { + fileMap[f.Name] = f + } + + // 校验版本 + if f, ok := fileMap["__sys__.json"]; ok { + rc, err := f.Open() + if err == nil { + var sysInfo map[string]interface{} + json.NewDecoder(rc).Decode(&sysInfo) + rc.Close() + if v, ok := sysInfo["version"]; ok { + vs, _ := v.(string) + if vs < "v3" { + return fmt.Errorf("只能数据随版本升级上来,当前备份版本为 %s,限制 v3 以下的不能导入", vs) + } + } + } + // } else { + // return fmt.Errorf("非法备份包:缺失版本标记") + } + + // 开启全局事务 + err = database.DB.Transaction(func(tx *gorm.DB) error { + // 1. 清空现有数据(物理删除) + tx.Where("1=1").Delete(&models.User{}) + tx.Where("1=1").Delete(&models.Task{}) + tx.Where("1=1").Delete(&models.TaskLog{}) + tx.Where("1=1").Delete(&models.EnvironmentVariable{}) + tx.Where("1=1").Delete(&models.Script{}) + tx.Where("section != ?", BackupSection).Delete(&models.Setting{}) + tx.Where("1=1").Delete(&models.SendStats{}) + + tx.Where("1=1").Delete(&models.Agent{}) + tx.Where("1=1").Delete(&models.AgentToken{}) + tx.Where("1=1").Delete(&models.Language{}) + tx.Where("1=1").Delete(&models.Dependency{}) + tx.Where("1=1").Delete(&models.NotifyWay{}) + tx.Where("1=1").Delete(&models.NotifyBinding{}) + tx.Where("1=1").Delete(&models.AppLog{}) + tx.Where("1=1").Delete(&models.DataStorage{}) + tx.Where("1=1").Delete(&models.DataRelation{}) + + // 2. 依次恢复每个表 + for _, cfg := range configs { + if f, ok := fileMap[cfg.filename]; ok { + if err := s.restoreFromZipFile(tx, f, cfg.filename); err != nil { + return err + } + } + } + + // 3. 恢复 scripts 文件夹 + s.restoreScriptsDir(r) + + return nil + }) + + if err == nil { + // 备份恢复成功后,需要同时刷新内存中的配置缓存以免数据不一致导致异常 + constant.Secret = s.settingsService.Get(constant.SectionSecurity, constant.KeySecret) + cache.LoadSiteCache() + } + + return err +} + +func restoreStreamBatch[T any](tx *gorm.DB, decoder *json.Decoder) error { + batchSize := 1000 + var batch []*T + + for decoder.More() { + var m T + if err := decoder.Decode(&m); err != nil { + return err + } + batch = append(batch, &m) + + if len(batch) >= batchSize { + if err := tx.Select("*").CreateInBatches(batch, batchSize).Error; err != nil { + return err + } + batch = nil // reset batch + } + } + + if len(batch) > 0 { + return tx.Select("*").CreateInBatches(batch, len(batch)).Error + } + + return nil +} + +func (s *BackupService) restoreFromZipFile(tx *gorm.DB, f *zip.File, filename string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + // 特殊处理设置表(设置表通常很小,直接反序列化) + if filename == "settings.json" { + data, _ := io.ReadAll(rc) + var settings []models.Setting + if err := json.Unmarshal(data, &settings); err == nil { + if len(settings) > 0 { + return tx.Select("*").Create(&settings).Error + } + } + return nil + } + + // 流式解析 JSON 数组 + decoder := json.NewDecoder(rc) + + // 找到数组开始 [ + if t, err := decoder.Token(); err != nil || t != json.Delim('[') { + return fmt.Errorf("invalid json format: expected %s", filename) + } + + switch filename { + case "users.json": + return restoreStreamBatch[models.User](tx, decoder) + case "tasks.json": + return s.restoreTasks(tx, decoder) + case "task_logs.json": + return restoreStreamBatch[models.TaskLog](tx, decoder) + case "envs.json": + return restoreStreamBatch[models.EnvironmentVariable](tx, decoder) + case "scripts.json": + return restoreStreamBatch[models.Script](tx, decoder) + case "send_stats.json": + return restoreStreamBatch[models.SendStats](tx, decoder) + + case "agents.json": + return restoreStreamBatch[models.Agent](tx, decoder) + case "tokens.json": + return restoreStreamBatch[models.AgentToken](tx, decoder) + case "languages.json": + return restoreStreamBatch[models.Language](tx, decoder) + case "deps.json": + return restoreStreamBatch[models.Dependency](tx, decoder) + case "notify_ways.json": + return restoreStreamBatch[models.NotifyWay](tx, decoder) + case "notify_bindings.json": + return restoreStreamBatch[models.NotifyBinding](tx, decoder) + case "app_logs.json": + return restoreStreamBatch[models.AppLog](tx, decoder) + case "data_storage.json": + return restoreStreamBatch[models.DataStorage](tx, decoder) + case "data_relations.json": + return restoreStreamBatch[models.DataRelation](tx, decoder) + default: + return nil + } +} + +func (s *BackupService) restoreTasks(tx *gorm.DB, decoder *json.Decoder) error { + batchSize := 1000 + var batch []*models.Task + + for decoder.More() { + var m models.Task + if err := decoder.Decode(&m); err != nil { + return err + } + batch = append(batch, &m) + + if len(batch) >= batchSize { + if err := s.insertTasksBatch(tx, batch); err != nil { + return err + } + batch = nil // reset + } + } + + if len(batch) > 0 { + return s.insertTasksBatch(tx, batch) + } + + return nil +} + +func (s *BackupService) insertTasksBatch(tx *gorm.DB, batch []*models.Task) error { + if err := tx.Select("*").CreateInBatches(batch, len(batch)).Error; err != nil { + return err + } + + // [COMPATIBILITY CODE - TEMPORARY] + // 为旧版备份数据做向下兼容(在旧版本中 tags/envs 还是直接存储在 tasks.json 中)。 + // 此段代码将旧数据迁移到新的 data_relations 和 data_storages 中,确保旧备份包能正常恢复。 + // 由于新版本的备份中 tasks.json 已不再包含这些字段,未来某次大版本更新后,这段兼容代码将被移除。 + for _, t := range batch { + if t.Tags != "" { + tags := strings.Split(t.Tags, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + var storage models.DataStorage + res := tx.Where("type = ? AND name = ?", constant.RelationTypeTaskTag, tag).Limit(1).Find(&storage) + if res.RowsAffected == 0 { + storage = models.DataStorage{ + ID: xid.New().String(), + Type: constant.RelationTypeTaskTag, + Name: tag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + tx.Create(&storage) + } + + var count int64 + tx.Model(&models.DataRelation{}).Where("data_id = ? AND relate_id = ? AND type = ?", t.ID, storage.ID, constant.RelationTypeTaskTag).Count(&count) + if count == 0 { + relation := models.DataRelation{ + ID: xid.New().String(), + DataID: t.ID, + RelateID: storage.ID, + Type: constant.RelationTypeTaskTag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + tx.Create(&relation) + } + } + } + + if string(t.Envs) != "" { + envs := strings.Split(string(t.Envs), ",") + for _, envID := range envs { + envID = strings.TrimSpace(envID) + if envID == "" { + continue + } + var count int64 + tx.Model(&models.DataRelation{}).Where("data_id = ? AND relate_id = ? AND type = ?", t.ID, envID, constant.RelationTypeTaskEnv).Count(&count) + if count == 0 { + relation := models.DataRelation{ + ID: xid.New().String(), + DataID: t.ID, + RelateID: envID, + Type: constant.RelationTypeTaskEnv, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + tx.Create(&relation) + } + } + } + } + return nil +} + +// insertRecords, restoreFromData 方法已合并入 restoreFromZipFile,此处删除冗余方法 + +func (s *BackupService) restoreScriptsDir(r *zip.ReadCloser) { + scriptsDir := constant.ScriptsWorkDir + for _, f := range r.File { + if len(f.Name) > 8 && f.Name[:8] == "scripts/" { + relPath := f.Name[8:] + if relPath == "" { + continue + } + fpath := filepath.Join(scriptsDir, relPath) + if f.FileInfo().IsDir() { + os.MkdirAll(fpath, 0755) + continue + } + os.MkdirAll(filepath.Dir(fpath), 0755) + if outFile, err := os.Create(fpath); err == nil { + if rc, err := f.Open(); err == nil { + io.Copy(outFile, rc) + rc.Close() + } + outFile.Close() + } + } + } +} + +func (s *BackupService) addDirToZip(zipWriter *zip.Writer, srcDir, prefix string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + zipPath := filepath.ToSlash(filepath.Join(prefix, relPath)) + if info.IsDir() { + if relPath != "." { + _, err := zipWriter.Create(zipPath + "/") + return err + } + return nil + } + w, err := zipWriter.Create(zipPath) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(w, file) + return err + }) +} + +func (s *BackupService) GetBackupFile() string { + var setting models.Setting + res := database.DB.Where(&models.Setting{Section: BackupSection, Key: BackupFileKey}).Limit(1).Find(&setting) + if res.Error != nil || res.RowsAffected == 0 { + return "" + } + return string(setting.Value) +} + +func (s *BackupService) ClearBackup() error { + filePath := s.GetBackupFile() + if filePath != "" { + os.Remove(filePath) + database.DB.Where(&models.Setting{Section: BackupSection, Key: BackupFileKey}).Delete(&models.Setting{}) + } + return nil +} diff --git a/internal/services/config_service.go b/internal/services/config_service.go new file mode 100644 index 0000000..8f3a4c8 --- /dev/null +++ b/internal/services/config_service.go @@ -0,0 +1,226 @@ +package services + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/utils" + + "gopkg.in/ini.v1" +) + +type ServerConfig struct { + Port int `ini:"port"` + Host string `ini:"host"` + URLPrefix string `ini:"url_prefix"` + PprofEnabled bool `ini:"pprof_enabled"` + CookieName string `ini:"cookie_name"` +} + +type DatabaseConfig struct { + Type string `ini:"type"` + Host string `ini:"host"` + Port int `ini:"port"` + User string `ini:"user"` + Password string `ini:"password"` + DBName string `ini:"dbname"` + Path string `ini:"path"` + DSN string `ini:"dsn"` + TablePrefix string `ini:"table_prefix"` + SSLMode string `ini:"ssl_mode"` +} + +type SecurityConfig struct { + Secret string `ini:"secret"` +} + +type RedisConfig struct { + Enabled bool `ini:"enabled"` + Host string `ini:"host"` + Port int `ini:"port"` + Password string `ini:"password"` + DB int `ini:"db"` +} + +type AppConfig struct { + Server ServerConfig `ini:"server"` + Database DatabaseConfig `ini:"database"` + Security SecurityConfig `ini:"security"` + Redis RedisConfig `ini:"redis"` +} + +var Config *AppConfig + +// getEnvStr 获取环境变量字符串 +func getEnvStr(key string, target *string) { + if v := os.Getenv(key); v != "" { + *target = v + _ = os.Unsetenv(key) + } +} + +// getEnvBool 获取环境变量布尔值 +func getEnvBool(key string, target *bool) { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + *target = b + } + _ = os.Unsetenv(key) + } +} + +// getEnvInt 获取环境变量整数 +func getEnvInt(key string, target *int) { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + *target = n + } + _ = os.Unsetenv(key) + } +} + +func LoadConfig(path string) (*AppConfig, error) { + // 路径发现逻辑:参数优先 -> 环境变量优先 -> 默认常量 + if path == "" { + if envPath := os.Getenv("BH_CONFIG_PATH"); envPath != "" { + path = envPath + } else { + path = constant.ConfigPath + } + } + + // 初始化默认配置 + Config = &AppConfig{ + Server: ServerConfig{ + Port: 8052, + Host: "0.0.0.0", + PprofEnabled: false, + CookieName: "BHToken", + }, + Database: DatabaseConfig{ + Type: "sqlite", + Host: "localhost", + Port: 3306, + User: "root", + Password: "", + DBName: "taskpool", + Path: constant.DefaultDBPath, + TablePrefix: "taskpool_", + }, + Security: SecurityConfig{ + Secret: "", + }, + } + + // 检查配置文件是否存在 + if _, err := os.Stat(path); err == nil { + // 配置文件存在,从文件加载 + logger.Infof("[Config] 从文件加载配置: %s", path) + cfg, err := ini.Load(path) + if err != nil { + return nil, err + } + if err := cfg.MapTo(Config); err != nil { + return nil, err + } + } else { + // 配置文件不存在,使用环境变量 + logger.Info("[Config] 配置文件不存在,从环境变量加载") + applyEnvOverrides() + } + + // 设置默认数据库路径 + if Config.Database.Path == "" { + Config.Database.Path = constant.DefaultDBPath + } + // sqlite:新路径不存在时,回退旧版 data/baihu.db,避免升级后丢库 + if Config.Database.Type == "sqlite" || Config.Database.Type == "" { + if _, err := os.Stat(Config.Database.Path); err != nil { + legacy := strings.ReplaceAll(Config.Database.Path, "taskpool.db", "baihu.db") + if legacy == Config.Database.Path { + legacy = filepath.Join(constant.DataDir, "baihu.db") + } + if _, err2 := os.Stat(legacy); err2 == nil { + logger.Infof("[Config] 使用旧版数据库文件: %s", legacy) + Config.Database.Path = legacy + } + } + } + // 表前缀:若仍是默认 taskpool_ 且配置文件未显式迁移,保留兼容旧库 baihu_ + if Config.Database.TablePrefix == "" { + Config.Database.TablePrefix = "taskpool_" + } + + // 设置配置到 constant 包 + if Config.Server.CookieName != "" { + constant.CookieName = Config.Server.CookieName + } + constant.TablePrefix = Config.Database.TablePrefix + constant.RuntimeDBType = Config.Database.Type + constant.RuntimeDBHost = Config.Database.Host + constant.RuntimeDBPort = Config.Database.Port + constant.RuntimeDBUser = Config.Database.User + constant.RuntimeDBPassword = Config.Database.Password + constant.RuntimeDBName = Config.Database.DBName + constant.RuntimeDBPath = Config.Database.Path + constant.RuntimeDBDSN = Config.Database.DSN + constant.RuntimeDBTablePrefix = Config.Database.TablePrefix + constant.RuntimeDBSSLMode = Config.Database.SSLMode + + // 暂存旧的 Secret,不再直接给 constant 赋值(改为到 settings 初始化时判断) + // constant.Secret = Config.Security.Secret + + // 设置演示模式 + if v := os.Getenv("BH_DEMO_MODE"); v == "true" || v == "1" { + constant.DemoMode = true + logger.Info("[Config] 演示模式已启用") + _ = os.Unsetenv("BH_DEMO_MODE") + } + + // 输出配置信息(隐藏敏感信息) + logger.Infof("[Config] 服务地址: %s:%d", Config.Server.Host, Config.Server.Port) + if Config.Server.URLPrefix != "" { + logger.Infof("[Config] URL前缀: %s", Config.Server.URLPrefix) + } + + maskedHost := utils.MaskString(Config.Database.Host) + maskedDBName := utils.MaskString(Config.Database.DBName) + logger.Infof("[Config] 数据库: type=%s, host=%s, port=%d, dbname=%s, dsn=%v", + Config.Database.Type, maskedHost, Config.Database.Port, maskedDBName, Config.Database.DSN != "") + + return Config, nil +} + +// applyEnvOverrides 从环境变量加载配置 +func applyEnvOverrides() { + // Server + getEnvInt("BH_SERVER_PORT", &Config.Server.Port) + getEnvStr("BH_SERVER_HOST", &Config.Server.Host) + getEnvStr("BH_SERVER_URL_PREFIX", &Config.Server.URLPrefix) + getEnvBool("BH_SERVER_PPROF", &Config.Server.PprofEnabled) + getEnvStr("BH_COOKIE_NAME", &Config.Server.CookieName) + + // Database + getEnvStr("BH_DB_TYPE", &Config.Database.Type) + getEnvStr("BH_DB_HOST", &Config.Database.Host) + getEnvInt("BH_DB_PORT", &Config.Database.Port) + getEnvStr("BH_DB_USER", &Config.Database.User) + getEnvStr("BH_DB_PASSWORD", &Config.Database.Password) + getEnvStr("BH_DB_NAME", &Config.Database.DBName) + getEnvStr("BH_DB_PATH", &Config.Database.Path) + getEnvStr("BH_DB_DSN", &Config.Database.DSN) + getEnvStr("BH_DB_TABLE_PREFIX", &Config.Database.TablePrefix) + getEnvStr("BH_DB_SSL_MODE", &Config.Database.SSLMode) + + // Security + getEnvStr("BH_SECRET", &Config.Security.Secret) + +} + +func GetConfig() *AppConfig { + return Config +} diff --git a/internal/services/data_service.go b/internal/services/data_service.go new file mode 100644 index 0000000..be3540a --- /dev/null +++ b/internal/services/data_service.go @@ -0,0 +1,350 @@ +package services + +import ( + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/relation" + "github.com/rs/xid" + "gorm.io/gorm" +) + +type DataService struct{} + +func NewDataService() *DataService { + return &DataService{} +} + +// ExportBusinessData 智能解析依赖并导出业务数据 +func (s *DataService) ExportBusinessData(taskIDs []string, envIDs []string) *models.ExportData { + export := models.NewExportData() + + export.Tasks = s.collectTasksAndRelations(taskIDs) + export.Envs = s.collectEnvironmentVariables(export.Tasks, envIDs) + export.Bindings = s.collectNotifyBindings(export.Tasks) + export.Tags = s.collectTagStorages(export.Tasks) + + return export +} + +// collectTasksAndRelations 收集任务及其子任务,并加载关联关系 (Envs, Tags) +func (s *DataService) collectTasksAndRelations(taskIDs []string) []models.Task { + if len(taskIDs) == 0 { + return nil + } + + var tasks []models.Task + targetTaskIDs := make(map[string]bool) + for _, id := range taskIDs { + targetTaskIDs[id] = true + } + + var initialTasks []models.Task + database.DB.Where("id IN ?", taskIDs).Find(&initialTasks) + tasks = append(tasks, initialTasks...) + + // 检查哪些是仓库任务,并递归查询其子任务 + var parentIDs []string + for _, t := range initialTasks { + if t.Type == "repo" { + parentIDs = append(parentIDs, t.ID) + } + } + + if len(parentIDs) > 0 { + var childTasks []models.Task + database.DB.Where("repo_task_id IN ?", parentIDs).Find(&childTasks) + for _, ct := range childTasks { + if !targetTaskIDs[ct.ID] { + tasks = append(tasks, ct) + targetTaskIDs[ct.ID] = true + } + } + } + + // 填充任务的 Envs 与 Tags 关系数据,以保证依赖解析和后续导入成功 + if len(tasks) > 0 { + var exportTaskIDs []string + for _, t := range tasks { + exportTaskIDs = append(exportTaskIDs, t.ID) + } + + envsMap := relation.DataRelation.LoadRelations(exportTaskIDs, constant.RelationTypeTaskEnv) + tagsMap := relation.DataRelation.LoadTags(exportTaskIDs, constant.RelationTypeTaskTag) + + for i, t := range tasks { + if envs, ok := envsMap[t.ID]; ok { + tasks[i].Envs = models.BigText(strings.Join(envs, ",")) + } + if tags, ok := tagsMap[t.ID]; ok { + tasks[i].Tags = strings.Join(tags, ",") + } + } + } + + return tasks +} + +// collectEnvironmentVariables 收集所需和任务所依赖的环境变量 +func (s *DataService) collectEnvironmentVariables(tasks []models.Task, envIDs []string) []models.EnvironmentVariable { + targetEnvIDs := make(map[string]bool) + for _, id := range envIDs { + targetEnvIDs[id] = true + } + + // 从任务中解析依赖的环境变量 + for _, t := range tasks { + if t.Envs != "" { + envArray := strings.Split(string(t.Envs), ",") + for _, eID := range envArray { + eID = strings.TrimSpace(eID) + if eID != "" { + targetEnvIDs[eID] = true + } + } + } + } + + if len(targetEnvIDs) == 0 { + return nil + } + + var finalEnvIDs []string + for id := range targetEnvIDs { + finalEnvIDs = append(finalEnvIDs, id) + } + + var envs []models.EnvironmentVariable + database.DB.Where("id IN ?", finalEnvIDs).Find(&envs) + return envs +} + +// collectNotifyBindings 收集相关的通知规则 (NotifyBindings) +func (s *DataService) collectNotifyBindings(tasks []models.Task) []models.NotifyBinding { + if len(tasks) == 0 { + return nil + } + + var taskIDList []string + for _, t := range tasks { + taskIDList = append(taskIDList, t.ID) + } + + var bindings []models.NotifyBinding + database.DB.Where("type = ? AND data_id IN ?", "task", taskIDList).Find(&bindings) + return bindings +} + +// collectTagStorages 收集标签定义 (DataStorage) +func (s *DataService) collectTagStorages(tasks []models.Task) []models.DataStorage { + targetTagNames := make(map[string]bool) + for _, t := range tasks { + if t.Tags != "" { + tagArray := strings.Split(t.Tags, ",") + for _, tagName := range tagArray { + tagName = strings.TrimSpace(tagName) + if tagName != "" { + targetTagNames[tagName] = true + } + } + } + } + + if len(targetTagNames) == 0 { + return nil + } + + var finalTagNames []string + for name := range targetTagNames { + finalTagNames = append(finalTagNames, name) + } + + var tagStorages []models.DataStorage + database.DB.Where("type = ? AND name IN ?", constant.RelationTypeTaskTag, finalTagNames).Find(&tagStorages) + return tagStorages +} + +// ImportBusinessData 导入业务数据 +func (s *DataService) ImportBusinessData(data *models.ExportData) error { + tx := database.DB.Begin() + if tx.Error != nil { + return tx.Error + } + + var adminUser models.User + if err := tx.Where("role = ?", constant.AdminRole).First(&adminUser).Error; err != nil { + tx.Rollback() + return err + } + + importer := &businessImporter{ + tx: tx, + adminID: adminUser.ID, + } + + // 1. 导入环境变量 + if err := importer.importEnvs(data.Envs); err != nil { + tx.Rollback() + return err + } + + // 2. 导入标签定义 (DataStorage) + if err := importer.importTags(data.Tags); err != nil { + tx.Rollback() + return err + } + + // 3. 导入任务及关联映射关系 + if err := importer.importTasks(data.Tasks); err != nil { + tx.Rollback() + return err + } + + // 4. 导入通知规则 + if err := importer.importBindings(data.Bindings); err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +type businessImporter struct { + tx *gorm.DB + adminID string +} + +func (importer *businessImporter) importEnvs(envs []models.EnvironmentVariable) error { + if len(envs) == 0 { + return nil + } + for _, env := range envs { + env.UserID = importer.adminID + if err := importer.tx.Save(&env).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTags(tags []models.DataStorage) error { + if len(tags) == 0 { + return nil + } + for _, tagStorage := range tags { + if err := importer.tx.Save(&tagStorage).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTasks(tasks []models.Task) error { + if len(tasks) == 0 { + return nil + } + for _, task := range tasks { + if err := importer.tx.Save(&task).Error; err != nil { + return err + } + + if err := importer.importTaskRelations(task); err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTaskRelations(task models.Task) error { + if err := importer.importTaskTagRelations(task); err != nil { + return err + } + return importer.importTaskEnvRelations(task) +} + +func (importer *businessImporter) importTaskTagRelations(task models.Task) error { + if task.Tags == "" { + return nil + } + if err := importer.tx.Where("data_id = ? AND type = ?", task.ID, constant.RelationTypeTaskTag).Delete(&models.DataRelation{}).Error; err != nil { + return err + } + tags := strings.Split(task.Tags, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + var storage models.DataStorage + res := importer.tx.Where("type = ? AND name = ?", constant.RelationTypeTaskTag, tag).Limit(1).Find(&storage) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + storage = models.DataStorage{ + ID: xid.New().String(), + Type: constant.RelationTypeTaskTag, + Name: tag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if err := importer.tx.Create(&storage).Error; err != nil { + return err + } + } + rel := models.DataRelation{ + ID: xid.New().String(), + DataID: task.ID, + RelateID: storage.ID, + Type: constant.RelationTypeTaskTag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if err := importer.tx.Create(&rel).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importTaskEnvRelations(task models.Task) error { + if string(task.Envs) == "" { + return nil + } + if err := importer.tx.Where("data_id = ? AND type = ?", task.ID, constant.RelationTypeTaskEnv).Delete(&models.DataRelation{}).Error; err != nil { + return err + } + ids := strings.Split(string(task.Envs), ",") + for _, relateID := range ids { + relateID = strings.TrimSpace(relateID) + if relateID == "" { + continue + } + rel := models.DataRelation{ + ID: xid.New().String(), + DataID: task.ID, + RelateID: relateID, + Type: constant.RelationTypeTaskEnv, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if err := importer.tx.Create(&rel).Error; err != nil { + return err + } + } + return nil +} + +func (importer *businessImporter) importBindings(bindings []models.NotifyBinding) error { + if len(bindings) == 0 { + return nil + } + for _, binding := range bindings { + if err := importer.tx.Save(&binding).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/services/dependency_service.go b/internal/services/dependency_service.go new file mode 100644 index 0000000..70c4fe3 --- /dev/null +++ b/internal/services/dependency_service.go @@ -0,0 +1,140 @@ +package services + +import ( + "errors" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/deps" + "github.com/engigu/taskpool/internal/utils" +) + +type DependencyService struct{} + +func NewDependencyService() *DependencyService { + return &DependencyService{} +} + +// List 获取依赖列表 +func (s *DependencyService) List(language, langVersion string) ([]models.Dependency, error) { + var results []models.Dependency + query := database.DB + if language != "" { + query = query.Where("language = ?", language) + } + if langVersion != "" { + query = query.Where("lang_version = ?", langVersion) + } + err := query.Order("id desc").Find(&results).Error + return results, err +} + +// Create 创建依赖记录 +func (s *DependencyService) Create(dep *models.Dependency) error { + // 检查是否已存在(名称、版本、语言及版本必须完全匹配) + var existing models.Dependency + res := database.DB.Where("name = ? AND version = ? AND language = ? AND lang_version = ?", dep.Name, dep.Version, dep.Language, dep.LangVersion).Limit(1).Find(&existing) + if res.Error == nil && res.RowsAffected > 0 { + // 如果已存在,更新 ID 并执行更新 + dep.ID = existing.ID + return database.DB.Model(&existing).Updates(dep).Error + } + + // 不存在则新建 + if dep.ID == "" { + dep.ID = utils.GenerateID() + } + return database.DB.Create(dep).Error +} + +// Delete 删除依赖记录 +func (s *DependencyService) Delete(id string) error { + return database.DB.Where("id = ?", id).Delete(&models.Dependency{}).Error +} + +// Install 安装依赖 +func (s *DependencyService) Install(dep *models.Dependency) error { + m := deps.GetManager(dep.Language) + if m == nil { + return errors.New("不支持的依赖类型: " + dep.Language) + } + return m.Install(dep) +} + +// Uninstall 卸载依赖 +func (s *DependencyService) Uninstall(dep *models.Dependency) error { + m := deps.GetManager(dep.Language) + if m == nil { + return errors.New("不支持的依赖类型: " + dep.Language) + } + return m.Uninstall(dep) +} + +// GetInstalledPackages 获取已安装的包列表 +func (s *DependencyService) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + m := deps.GetManager(language) + if m == nil { + return nil, errors.New("不支持的依赖类型: " + language) + } + return m.GetInstalledPackages(language, langVersion) +} + +// GetInstallCommand 获取安装命令 +func (s *DependencyService) GetInstallCommand(dep *models.Dependency) (string, error) { + m := deps.GetManager(dep.Language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + dep.Language) + } + return m.GetInstallCommand(dep) +} + +// GetReinstallAllCommand 获取全部重装命令 +func (s *DependencyService) GetReinstallAllCommand(language, langVersion string) (string, error) { + m := deps.GetManager(language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + language) + } + + deps_list, err := s.List(language, langVersion) + if err != nil { + return "", err + } + + return m.GetReinstallAllCommand(deps_list) +} + +// GetVerifyCommand 获取环境验证命令 +func (s *DependencyService) GetVerifyCommand(language, langVersion string) (string, error) { + m := deps.GetManager(language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + language) + } + return m.GetVerifyCommand(langVersion) +} + +// GetBatchInstallCommand 获取批量安装命令 +func (s *DependencyService) GetBatchInstallCommand(depsList []models.Dependency) (string, error) { + if len(depsList) == 0 { + return "", errors.New("依赖包列表不能为空") + } + + firstDep := depsList[0] + m := deps.GetManager(firstDep.Language) + if m == nil { + return "", errors.New("不支持的依赖类型: " + firstDep.Language) + } + + return m.GetBatchInstallCommand(depsList) +} + +// ImportDependencies 批量导入依赖并自动入库去重 +func (s *DependencyService) ImportDependencies(depsList []models.Dependency) ([]models.Dependency, error) { + var imported []models.Dependency + for i := range depsList { + dep := &depsList[i] + if err := s.Create(dep); err == nil { + imported = append(imported, *dep) + } + } + return imported, nil +} diff --git a/internal/services/deps/bun.go b/internal/services/deps/bun.go new file mode 100644 index 0000000..754789f --- /dev/null +++ b/internal/services/deps/bun.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type BunManager struct { + BaseManager +} + +func NewBunManager(language string) *BunManager { + return &BunManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"bun", "add", "-g"}, + UninstallCmd: []string{"bun", "remove", "-g"}, + ListCmd: []string{"bun", "pm", "ls", "-g"}, + Separator: "@", + }, + } +} + +func (m *BunManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/crystal.go b/internal/services/deps/crystal.go new file mode 100644 index 0000000..3edea07 --- /dev/null +++ b/internal/services/deps/crystal.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type CrystalManager struct { + BaseManager +} + +func NewCrystalManager(language string) *CrystalManager { + return &CrystalManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"shards", "install"}, // 主要是针对当前目录,但 mise exec 下可以安装 tools + UninstallCmd: []string{"rm", "-rf"}, // 这里的逻辑可能不完全正确 + ListCmd: []string{"shards", "list"}, + Separator: " ", + }, + } +} + +func (m *CrystalManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Shards") { + continue + } + fields := strings.Fields(line) + if len(fields) > 1 { + packages = append(packages, models.Dependency{Name: fields[1], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/dart.go b/internal/services/deps/dart.go new file mode 100644 index 0000000..f8ec914 --- /dev/null +++ b/internal/services/deps/dart.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type DartManager struct { + BaseManager +} + +func NewDartManager(language string) *DartManager { + return &DartManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"dart", "pub", "global", "activate"}, + UninstallCmd: []string{"dart", "pub", "global", "deactivate"}, + ListCmd: []string{"dart", "pub", "global", "list"}, + Separator: " ", + }, + } +} + +func (m *DartManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/deno.go b/internal/services/deps/deno.go new file mode 100644 index 0000000..49b06d5 --- /dev/null +++ b/internal/services/deps/deno.go @@ -0,0 +1,26 @@ +package deps + +import ( + "github.com/engigu/taskpool/internal/models" +) + +type DenoManager struct { + BaseManager +} + +func NewDenoManager(language string) *DenoManager { + return &DenoManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"deno", "install", "--global", "-A"}, + UninstallCmd: []string{"deno", "uninstall"}, + ListCmd: []string{"ls", "-1", "/root/.deno/bin"}, // 这是一个简单的 fallback 尝试 + Separator: "@", + }, + } +} + +func (m *DenoManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + // Deno 没有官方的 list 命令,暂时留空或通过文件系统猜测 + return []models.Dependency{}, nil +} diff --git a/internal/services/deps/detector.go b/internal/services/deps/detector.go new file mode 100644 index 0000000..9970bfd --- /dev/null +++ b/internal/services/deps/detector.go @@ -0,0 +1,103 @@ +package deps + +import ( + "regexp" + "strings" +) + +// Detector 依赖检测器接口 +type Detector interface { + Detect(logContent string) []string +} + +// PythonDetector Python 依赖检测器 +type PythonDetector struct{} + +func (d *PythonDetector) Detect(logContent string) []string { + var pkgs []string + seen := make(map[string]bool) + pythonRegex1 := regexp.MustCompile(`ModuleNotFoundError: No module named '([^']+)'`) + pythonRegex2 := regexp.MustCompile(`No module named ([a-zA-Z0-9_\-]+)`) + + matches := pythonRegex1.FindAllStringSubmatch(logContent, -1) + for _, m := range matches { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + matches2 := pythonRegex2.FindAllStringSubmatch(logContent, -1) + for _, m := range matches2 { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + return pkgs +} + +// NodeDetector Node.js 依赖检测器 +type NodeDetector struct{} + +func (d *NodeDetector) Detect(logContent string) []string { + var pkgs []string + seen := make(map[string]bool) + nodeRegex1 := regexp.MustCompile(`Error: Cannot find module '([^']+)'`) + nodeRegex2 := regexp.MustCompile(`Cannot find module '([^']+)'`) + + matches := nodeRegex1.FindAllStringSubmatch(logContent, -1) + for _, m := range matches { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + matches2 := nodeRegex2.FindAllStringSubmatch(logContent, -1) + for _, m := range matches2 { + if len(m) > 1 { + name := strings.TrimSpace(m[1]) + if name != "" && !seen[name] { + seen[name] = true + pkgs = append(pkgs, name) + } + } + } + return pkgs +} + +var languageDetectors = map[string]Detector{ + "python": &PythonDetector{}, + "python3": &PythonDetector{}, + "node": &NodeDetector{}, + "js": &NodeDetector{}, + "ts": &NodeDetector{}, + "bun": &NodeDetector{}, +} + +// DetectMissingDependencies 从日志内容中检测缺失的依赖包名 +func DetectMissingDependencies(language, logContent string) ([]string, bool) { + lang := strings.ToLower(language) + var det Detector + for key, d := range languageDetectors { + if strings.Contains(lang, key) { + det = d + break + } + } + + if det == nil { + return nil, false + } + + pkgs := det.Detect(logContent) + return pkgs, len(pkgs) > 0 +} diff --git a/internal/services/deps/detector_test.go b/internal/services/deps/detector_test.go new file mode 100644 index 0000000..1ab4d87 --- /dev/null +++ b/internal/services/deps/detector_test.go @@ -0,0 +1,62 @@ +package deps + +import ( + "testing" +) + +func TestDetectMissingDependencies(t *testing.T) { + tests := []struct { + name string + language string + logContent string + wantPkgs []string + wantFound bool + }{ + { + name: "Python ModuleNotFoundError", + language: "python3", + logContent: "Traceback (most recent call last):\n File \"main.py\", line 1, in \n import requests\nModuleNotFoundError: No module named 'requests'", + wantPkgs: []string{"requests"}, + wantFound: true, + }, + { + name: "Python No module named", + language: "python", + logContent: "ImportError: No module named yaml", + wantPkgs: []string{"yaml"}, + wantFound: true, + }, + { + name: "Node Error Cannot find module", + language: "node", + logContent: "Error: Cannot find module 'axios'\nRequire stack:\n- /app/index.js", + wantPkgs: []string{"axios"}, + wantFound: true, + }, + { + name: "No match", + language: "python", + logContent: "Success running script", + wantPkgs: nil, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPkgs, gotFound := DetectMissingDependencies(tt.language, tt.logContent) + if gotFound != tt.wantFound { + t.Errorf("DetectMissingDependencies() gotFound = %v, want %v", gotFound, tt.wantFound) + } + if len(gotPkgs) != len(tt.wantPkgs) { + t.Errorf("DetectMissingDependencies() gotPkgs = %v, want %v", gotPkgs, tt.wantPkgs) + return + } + for i, p := range gotPkgs { + if p != tt.wantPkgs[i] { + t.Errorf("DetectMissingDependencies() gotPkgs[%d] = %v, want %v", i, p, tt.wantPkgs[i]) + } + } + }) + } +} diff --git a/internal/services/deps/dotnet.go b/internal/services/deps/dotnet.go new file mode 100644 index 0000000..ad01041 --- /dev/null +++ b/internal/services/deps/dotnet.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type DotnetManager struct { + BaseManager +} + +func NewDotnetManager(language string) *DotnetManager { + return &DotnetManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"dotnet", "tool", "install", "-g"}, + UninstallCmd: []string{"dotnet", "tool", "uninstall", "-g"}, + ListCmd: []string{"dotnet", "tool", "list", "-g"}, + Separator: " ", + }, + } +} + +func (m *DotnetManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + if i < 2 || line == "" { // 跳过表头 + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language, Version: fields[1]}) + } + } + return packages, nil +} diff --git a/internal/services/deps/elixir.go b/internal/services/deps/elixir.go new file mode 100644 index 0000000..7c5c773 --- /dev/null +++ b/internal/services/deps/elixir.go @@ -0,0 +1,50 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type ElixirManager struct { + BaseManager +} + +func NewElixirManager(language string) *ElixirManager { + verifyCmd := []string{"elixir", "-v"} + if strings.Contains(strings.ToLower(language), "erlang") { + verifyCmd = []string{"erl", "+V"} + } + return &ElixirManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"mix", "archive.install", "hex", "--force"}, + UninstallCmd: []string{"mix", "archive.uninstall"}, + ListCmd: []string{"mix", "archive"}, + VerifyCmd: verifyCmd, + Separator: " ", + }, + } +} + +func (m *ElixirManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "*") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/go.go b/internal/services/deps/go.go new file mode 100644 index 0000000..ad48c4f --- /dev/null +++ b/internal/services/deps/go.go @@ -0,0 +1,46 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type GoManager struct { + BaseManager +} + +func NewGoManager(language string) *GoManager { + return &GoManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"go", "install"}, + UninstallCmd: []string{"go", "clean", "-i"}, + ListCmd: []string{"go", "list", "..."}, + VerifyCmd: []string{"go", "version"}, + Separator: "@", + }, + } +} + +func (m *GoManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/lua.go b/internal/services/deps/lua.go new file mode 100644 index 0000000..c851615 --- /dev/null +++ b/internal/services/deps/lua.go @@ -0,0 +1,46 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type LuaManager struct { + BaseManager +} + +func NewLuaManager(language string) *LuaManager { + return &LuaManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"luarocks", "install"}, + UninstallCmd: []string{"luarocks", "remove"}, + ListCmd: []string{"luarocks", "list"}, + VerifyCmd: []string{"lua", "-v"}, + Separator: " ", + }, + } +} + +func (m *LuaManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Rock") || strings.HasPrefix(line, "--") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/manager.go b/internal/services/deps/manager.go new file mode 100644 index 0000000..a6503f1 --- /dev/null +++ b/internal/services/deps/manager.go @@ -0,0 +1,202 @@ +package deps + +import ( + "errors" + "os/exec" + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +// Manager 依赖管理器接口 +type Manager interface { + Install(dep *models.Dependency) error + Uninstall(dep *models.Dependency) error + GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) + GetInstallCommand(dep *models.Dependency) (string, error) + GetBatchInstallCommand(deps []models.Dependency) (string, error) + GetReinstallAllCommand(deps []models.Dependency) (string, error) + GetVerifyCommand(langVersion string) (string, error) +} + +// BaseManager 基础管理器,提供通用方法 +type BaseManager struct { + Language string + InstallCmd []string + UninstallCmd []string + ListCmd []string + VerifyCmd []string + Separator string +} + +func (m *BaseManager) runMiseCommand(langVersion string, cmdArgs []string) ([]byte, error) { + args := utils.BuildMiseCommandArgsSimple(cmdArgs, m.Language, langVersion) + cmd := exec.Command(args[0], args[1:]...) + return cmd.CombinedOutput() +} + +func (m *BaseManager) Install(dep *models.Dependency) error { + var packageSpec string + if dep.Version != "" { + packageSpec = dep.Name + m.Separator + dep.Version + } else { + packageSpec = dep.Name + } + + args := append([]string{}, m.InstallCmd...) + args = append(args, packageSpec) + + logger.Infof("Installing %s package: %s", m.Language, packageSpec) + output, err := m.runMiseCommand(dep.LangVersion, args) + dep.Log = models.BigText(output) + + if err != nil { + logger.Errorf("Install failed: %v, output: %s", err, string(output)) + return errors.New("安装失败: " + string(output)) + } + logger.Infof("Install success: %s", packageSpec) + return nil +} + +func (m *BaseManager) GetInstallCommand(dep *models.Dependency) (string, error) { + var packageSpec string + if dep.Version != "" { + packageSpec = dep.Name + m.Separator + dep.Version + } else { + packageSpec = dep.Name + } + + args := append([]string{}, m.InstallCmd...) + args = append(args, packageSpec) + + fullCmd := utils.BuildMiseCommandSimple(strings.Join(args, " "), m.Language, dep.LangVersion) + return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil +} + +func (m *BaseManager) GetBatchInstallCommand(deps []models.Dependency) (string, error) { + if len(deps) == 0 { + return "echo \"没有需要安装的依赖\"", nil + } + + var packageSpecs []string + var langVersion string + var language string + for _, dep := range deps { + language = dep.Language + if dep.LangVersion != "" { + langVersion = dep.LangVersion + } + if dep.Version != "" { + packageSpecs = append(packageSpecs, dep.Name+m.Separator+dep.Version) + } else { + packageSpecs = append(packageSpecs, dep.Name) + } + } + + args := append([]string{}, m.InstallCmd...) + args = append(args, packageSpecs...) + + fullCmd := utils.BuildMiseCommandSimple(strings.Join(args, " "), language, langVersion) + return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil +} + +func (m *BaseManager) GetReinstallAllCommand(deps []models.Dependency) (string, error) { + if len(deps) == 0 { + return "echo \"没有需要安装的依赖\"", nil + } + + var packageSpecs []string + var langVersion string + for _, dep := range deps { + if dep.LangVersion != "" { + langVersion = dep.LangVersion + } + if dep.Version != "" { + packageSpecs = append(packageSpecs, dep.Name+m.Separator+dep.Version) + } else { + packageSpecs = append(packageSpecs, dep.Name) + } + } + + args := append([]string{}, m.InstallCmd...) + args = append(args, packageSpecs...) + + fullCmd := utils.BuildMiseCommandSimple(strings.Join(args, " "), m.Language, langVersion) + return fullCmd + " && echo \"__INSTALL_SUCCESS__\" || echo \"__INSTALL_FAILED__\"", nil +} + +func (m *BaseManager) GetVerifyCommand(langVersion string) (string, error) { + var cmd string + if len(m.VerifyCmd) > 0 { + cmd = strings.Join(m.VerifyCmd, " ") + } else { + cmd = m.Language + " --version" + } + return utils.BuildMiseCommandSimple(cmd, m.Language, langVersion), nil +} + +func (m *BaseManager) Uninstall(dep *models.Dependency) error { + args := append([]string{}, m.UninstallCmd...) + args = append(args, dep.Name) + + logger.Infof("Uninstalling %s package: %s", m.Language, dep.Name) + output, err := m.runMiseCommand(dep.LangVersion, args) + if err != nil { + logger.Errorf("Uninstall failed: %v, output: %s", err, string(output)) + return errors.New("卸载失败: " + string(output)) + } + return nil +} + +// GetManager 根据语言获取对应的管理器 +func GetManager(language string) Manager { + lang := strings.ToLower(language) + if strings.Contains(lang, "python") { + return NewPythonManager(language) + } + if strings.Contains(lang, "node") { + return NewNodeManager(language) + } + if strings.Contains(lang, "ruby") { + return NewRubyManager(language) + } + if strings.Contains(lang, "go") { + return NewGoManager(language) + } + if strings.Contains(lang, "rust") { + return NewRustManager(language) + } + if strings.Contains(lang, "bun") { + return NewBunManager(language) + } + if strings.Contains(lang, "php") { + return NewPhpManager(language) + } + if strings.Contains(lang, "deno") { + return NewDenoManager(language) + } + if strings.Contains(lang, "dotnet") { + return NewDotnetManager(language) + } + if strings.Contains(lang, "elixir") || strings.Contains(lang, "erlang") { + return NewElixirManager(language) + } + if strings.Contains(lang, "lua") { + return NewLuaManager(language) + } + if strings.Contains(lang, "nim") { + return NewNimManager(language) + } + if strings.Contains(lang, "dart") || strings.Contains(lang, "flutter") { + return NewDartManager(language) + } + if strings.Contains(lang, "perl") { + return NewPerlManager(language) + } + if strings.Contains(lang, "crystal") { + return NewCrystalManager(language) + } + return nil +} diff --git a/internal/services/deps/nim.go b/internal/services/deps/nim.go new file mode 100644 index 0000000..9a9c287 --- /dev/null +++ b/internal/services/deps/nim.go @@ -0,0 +1,45 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type NimManager struct { + BaseManager +} + +func NewNimManager(language string) *NimManager { + return &NimManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"nimble", "install", "-y"}, + UninstallCmd: []string{"nimble", "uninstall", "-y"}, + ListCmd: []string{"nimble", "list", "-i"}, + Separator: "@", + }, + } +} + +func (m *NimManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "[") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/node.go b/internal/services/deps/node.go new file mode 100644 index 0000000..675cc90 --- /dev/null +++ b/internal/services/deps/node.go @@ -0,0 +1,48 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type NodeManager struct { + BaseManager +} + +func NewNodeManager(language string) *NodeManager { + return &NodeManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"npm", "install", "-g"}, + UninstallCmd: []string{"npm", "uninstall", "-g"}, + ListCmd: []string{"npm", "list", "-g", "--depth=0", "--json"}, + VerifyCmd: []string{"node", "-v"}, + Separator: "@", + }, + } +} + +func (m *NodeManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, `"version"`) || strings.HasPrefix(line, "{") || strings.HasPrefix(line, "}") || strings.HasPrefix(line, "]") { + continue + } + if strings.HasPrefix(line, `"`) && strings.Contains(line, ":") { + name := strings.Trim(strings.Split(line, ":")[0], `" `) + if name != "" && name != "dependencies" && name != "name" { + packages = append(packages, models.Dependency{Name: name, Language: language}) + } + } + } + return packages, nil +} diff --git a/internal/services/deps/parser.go b/internal/services/deps/parser.go new file mode 100644 index 0000000..0f9034d --- /dev/null +++ b/internal/services/deps/parser.go @@ -0,0 +1,105 @@ +package deps + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/engigu/taskpool/internal/models" +) + +// ParseManifest 根据语言解析依赖清单文件内容 +func ParseManifest(language, content string) ([]models.Dependency, error) { + lang := strings.ToLower(language) + if strings.Contains(lang, "python") { + return ParseRequirements(content), nil + } + if strings.Contains(lang, "node") { + return ParsePackageJson(content) + } + return []models.Dependency{}, nil +} + +// ParseRequirements 解析 Python requirements.txt +func ParseRequirements(content string) []models.Dependency { + var deps []models.Dependency + // 使用正则表达式按行分割,兼容 Windows 和 Linux 的换行符 + lines := regexp.MustCompile(`\r?\n`).Split(content, -1) + + // 用于分割包名和版本的正则 (支持 ==, >=, <=, ~=, >, <, @) + versionRegex := regexp.MustCompile(`[=><~@]+`) + + for _, line := range lines { + line = strings.TrimSpace(line) + // 忽略空行、注释行以及参数行 (以 - 开头的行如 -i, -r) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + + // 分割名称与版本 + parts := versionRegex.Split(line, 2) + name := strings.TrimSpace(parts[0]) + version := "" + if len(parts) > 1 { + // 清除可能存在的后续参数,比如 requests==2.31.0 --hash=sha256:... + versionPart := strings.TrimSpace(parts[1]) + + // 如果有逗号分隔的多个范围限制,比如 >=1.20,<2.0,只取第一个范围作为参考版本号 + if idx := strings.Index(versionPart, ","); idx != -1 { + versionPart = versionPart[:idx] + } + + versionFields := strings.Fields(versionPart) + if len(versionFields) > 0 { + version = strings.TrimSpace(versionFields[0]) + // 清除可能残留的首部版本符号 + version = strings.TrimLeft(version, "=><~@ ") + } + } + + if name != "" { + deps = append(deps, models.Dependency{ + Name: name, + Version: version, + Language: "python3", + }) + } + } + return deps +} + +// PackageJson 代表 package.json 的结构定义 +type PackageJson struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +// ParsePackageJson 解析 Node.js package.json +func ParsePackageJson(content string) ([]models.Dependency, error) { + var pkg PackageJson + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil, err + } + + var deps []models.Dependency + collect := func(m map[string]string, isDev bool) { + for name, versionRange := range m { + // 移除 npm 常见版本范围修饰符(如 ^1.2.3 或 ~2.3.0,保留底线版本号) + version := strings.TrimLeft(versionRange, "^~>=<* ") + remark := "" + if isDev { + remark = "devDependencies" + } + deps = append(deps, models.Dependency{ + Name: name, + Version: version, + Language: "node", + Remark: remark, + }) + } + } + + collect(pkg.Dependencies, false) + collect(pkg.DevDependencies, true) + return deps, nil +} diff --git a/internal/services/deps/parser_test.go b/internal/services/deps/parser_test.go new file mode 100644 index 0000000..d584ebe --- /dev/null +++ b/internal/services/deps/parser_test.go @@ -0,0 +1,87 @@ +package deps + +import ( + "testing" +) + +func TestParseRequirements(t *testing.T) { + content := ` +# This is a comment +requests==2.31.0 +numpy>=1.20,<2.0 +gunicorn +-r other-requirements.txt + pandas ~= 1.3.0 +` + deps := ParseRequirements(content) + if len(deps) != 4 { + t.Fatalf("expected 4 dependencies, got %d", len(deps)) + } + + expected := []struct { + name string + version string + }{ + {"requests", "2.31.0"}, + {"numpy", "1.20"}, + {"gunicorn", ""}, + {"pandas", "1.3.0"}, + } + + for i, exp := range expected { + if deps[i].Name != exp.name { + t.Errorf("expected name %s, got %s", exp.name, deps[i].Name) + } + if deps[i].Version != exp.version { + t.Errorf("expected version %s, got %s", exp.version, deps[i].Version) + } + if deps[i].Language != "python3" { + t.Errorf("expected language python3, got %s", deps[i].Language) + } + } +} + +func TestParsePackageJson(t *testing.T) { + content := `{ + "dependencies": { + "lodash": "^4.17.21", + "express": "~4.18.2" + }, + "devDependencies": { + "typescript": "^5.0.4" + } +}` + deps, err := ParsePackageJson(content) + if err != nil { + t.Fatalf("failed to parse package.json: %v", err) + } + + if len(deps) != 3 { + t.Fatalf("expected 3 dependencies, got %d", len(deps)) + } + + expected := []struct { + name string + version string + remark string + }{ + {"lodash", "4.17.21", ""}, + {"express", "4.18.2", ""}, + {"typescript", "5.0.4", "devDependencies"}, + } + + for i, exp := range expected { + if deps[i].Name != exp.name { + t.Errorf("expected name %s, got %s", exp.name, deps[i].Name) + } + if deps[i].Version != exp.version { + t.Errorf("expected version %s, got %s", exp.version, deps[i].Version) + } + if deps[i].Remark != exp.remark { + t.Errorf("expected remark %s, got %s", exp.remark, deps[i].Remark) + } + if deps[i].Language != "node" { + t.Errorf("expected language node, got %s", deps[i].Language) + } + } +} diff --git a/internal/services/deps/perl.go b/internal/services/deps/perl.go new file mode 100644 index 0000000..aaa47da --- /dev/null +++ b/internal/services/deps/perl.go @@ -0,0 +1,27 @@ +package deps + +import ( + "github.com/engigu/taskpool/internal/models" +) + +type PerlManager struct { + BaseManager +} + +func NewPerlManager(language string) *PerlManager { + return &PerlManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"cpanm"}, // 需要系统预装 cpanm + UninstallCmd: []string{"cpanm", "--uninstall"}, // 部分 cpanm 版本不支持,这是一个占位 + ListCmd: []string{"perldoc", "-l"}, // 很难列出所有,暂时简单处理 + VerifyCmd: []string{"perl", "-v"}, + Separator: " ", + }, + } +} + +func (m *PerlManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + // Perl 依赖列出比较复杂,暂时返回空 + return []models.Dependency{}, nil +} diff --git a/internal/services/deps/php.go b/internal/services/deps/php.go new file mode 100644 index 0000000..31c5f4c --- /dev/null +++ b/internal/services/deps/php.go @@ -0,0 +1,46 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type PhpManager struct { + BaseManager +} + +func NewPhpManager(language string) *PhpManager { + return &PhpManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"composer", "global", "require"}, + UninstallCmd: []string{"composer", "global", "remove"}, + ListCmd: []string{"composer", "global", "show", "--name-only"}, + VerifyCmd: []string{"php", "-v"}, + Separator: ":", + }, + } +} + +func (m *PhpManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/python.go b/internal/services/deps/python.go new file mode 100644 index 0000000..fd6372d --- /dev/null +++ b/internal/services/deps/python.go @@ -0,0 +1,47 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type PythonManager struct { + BaseManager +} + +func NewPythonManager(language string) *PythonManager { + return &PythonManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"pip", "install"}, + UninstallCmd: []string{"pip", "uninstall", "-y"}, + ListCmd: []string{"pip", "list", "--format=freeze"}, + Separator: "==", + }, + } +} + +func (m *PythonManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "==", 2) + pkg := models.Dependency{Name: parts[0], Language: language} + if len(parts) > 1 { + pkg.Version = parts[1] + } + packages = append(packages, pkg) + } + return packages, nil +} diff --git a/internal/services/deps/ruby.go b/internal/services/deps/ruby.go new file mode 100644 index 0000000..166c887 --- /dev/null +++ b/internal/services/deps/ruby.go @@ -0,0 +1,46 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type RubyManager struct { + BaseManager +} + +func NewRubyManager(language string) *RubyManager { + return &RubyManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"gem", "install"}, + UninstallCmd: []string{"gem", "uninstall", "-a", "-x"}, + ListCmd: []string{"gem", "list", "--local"}, + VerifyCmd: []string{"ruby", "-v"}, + Separator: " ", + }, + } +} + +func (m *RubyManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Tool") || strings.HasPrefix(line, "(") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/deps/rust.go b/internal/services/deps/rust.go new file mode 100644 index 0000000..dfc1ef8 --- /dev/null +++ b/internal/services/deps/rust.go @@ -0,0 +1,46 @@ +package deps + +import ( + "strings" + + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" +) + +type RustManager struct { + BaseManager +} + +func NewRustManager(language string) *RustManager { + return &RustManager{ + BaseManager: BaseManager{ + Language: language, + InstallCmd: []string{"cargo", "install"}, + UninstallCmd: []string{"cargo", "uninstall"}, + ListCmd: []string{"cargo", "install", "--list"}, + VerifyCmd: []string{"rustc", "--version"}, + Separator: " v", + }, + } +} + +func (m *RustManager) GetInstalledPackages(language, langVersion string) ([]models.Dependency, error) { + output, err := m.runMiseCommand(langVersion, m.ListCmd) + if err != nil { + logger.Warnf("GetInstalledPackages for %s failed: %v", language, err) + } + + var packages []models.Dependency + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, " v") { + continue + } + fields := strings.Fields(line) + if len(fields) > 0 { + packages = append(packages, models.Dependency{Name: fields[0], Language: language}) + } + } + return packages, nil +} diff --git a/internal/services/env_service.go b/internal/services/env_service.go new file mode 100644 index 0000000..f96254f --- /dev/null +++ b/internal/services/env_service.go @@ -0,0 +1,409 @@ +package services + +import ( + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/relation" + "github.com/engigu/taskpool/internal/utils" + + "gorm.io/gorm" +) + +type EnvService struct{} + +func NewEnvService() *EnvService { + return &EnvService{} +} + +func (es *EnvService) CreateEnvVar(name, value, remark, envType string, hidden, enabled bool, userID string) *models.EnvironmentVariable { + if envType == constant.EnvTypeSecret { + if encValue, err := utils.Encrypt(value); err == nil { + value = encValue + } + } + + env := &models.EnvironmentVariable{ + ID: utils.GenerateID(), + Name: name, + Value: models.BigText(value), + Remark: remark, + Type: envType, + Hidden: &hidden, + Enabled: &enabled, + UserID: userID, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + database.DB.Select("*").Create(env) + return env +} + +func (es *EnvService) GetEnvVarsByUserID(userID string) []models.EnvironmentVariable { + var envs []models.EnvironmentVariable + database.DB.Where("user_id = ?", userID).Find(&envs) + es.LoadEnvTags(envs) + return envs +} + +// GetFormattedEnvVarsByUserID 获取用户环境变量并格式化为 NAME=VALUE 格式(支持重名合并) +func (es *EnvService) GetFormattedEnvVarsByUserID(userID string) []string { + envs := es.GetEnvVarsByUserID(userID) + return es.formatEnvVars(envs) +} + +func (es *EnvService) GetEnvVarsWithPagination(userID string, name string, envType string, tags string, page, pageSize int) ([]models.EnvironmentVariable, int64) { + var envs []models.EnvironmentVariable + var total int64 + + query := database.DB.Model(&models.EnvironmentVariable{}).Where("user_id = ?", userID) + if name != "" { + query = query.Where("name LIKE ? OR remark LIKE ?", "%"+name+"%", "%"+name+"%") + } + if envType != "" { + query = query.Where("type = ?", envType) + } + + if tags != "" { + tagList := strings.Split(tags, ",") + var validTags []string + for _, t := range tagList { + t = strings.TrimSpace(t) + if t != "" { + validTags = append(validTags, t) + } + } + if len(validTags) > 0 { + var storageIDs []string + database.DB.Model(&models.DataStorage{}).Where("type = ? AND name IN ?", constant.RelationTypeEnvTag, validTags).Pluck("id", &storageIDs) + + var envIDs []string + if len(storageIDs) > 0 { + database.DB.Model(&models.DataRelation{}).Where("type = ? AND relate_id IN ?", constant.RelationTypeEnvTag, storageIDs).Pluck("data_id", &envIDs) + } + + if len(envIDs) > 0 { + query = query.Where("id IN ?", envIDs) + } else { + query = query.Where("1 = 0") + } + } + } + + query.Count(&total) + query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&envs) + es.LoadEnvTags(envs) + return envs, total +} + +func (es *EnvService) GetEnvVarByID(id string) *models.EnvironmentVariable { + var env models.EnvironmentVariable + res := database.DB.Where("id = ?", id).Limit(1).Find(&env) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + envs := []models.EnvironmentVariable{env} + es.LoadEnvTags(envs) + return &envs[0] +} + +func (es *EnvService) UpdateEnvVar(id string, name, value, remark, envType string, hidden, enabled bool) *models.EnvironmentVariable { + var env models.EnvironmentVariable + res := database.DB.Where("id = ?", id).Limit(1).Find(&env) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + + if envType == constant.EnvTypeSecret && value != "********" && value != "" { + if encValue, err := utils.Encrypt(value); err == nil { + value = encValue + } + } else if envType == constant.EnvTypeSecret && (value == "********" || value == "") { + // Keep the original encrypted value + value = string(env.Value) + } + + updates := map[string]interface{}{ + "name": name, + "value": models.BigText(value), + "remark": remark, + "type": envType, + "hidden": &hidden, + "enabled": &enabled, + } + database.DB.Model(&env).Updates(updates) + return &env +} + +func (es *EnvService) GetAssociatedTasks(id string) []models.Task { + var associatedTasks []models.Task + var taskIDs []string + database.DB.Model(&models.DataRelation{}).Where("type = ? AND relate_id = ?", constant.RelationTypeTaskEnv, id).Pluck("data_id", &taskIDs) + if len(taskIDs) > 0 { + database.DB.Where("id IN ?", taskIDs).Find(&associatedTasks) + } + return associatedTasks +} + +func (es *EnvService) DeleteEnvVar(id string, force bool) (bool, []models.Task) { + associatedTasks := es.GetAssociatedTasks(id) + + if len(associatedTasks) > 0 && !force { + return false, associatedTasks + } + + if force { + err := database.DB.Transaction(func(tx *gorm.DB) error { + // Delete the relations mapping this env to any tasks + if err := tx.Where("type = ? AND relate_id = ?", constant.RelationTypeTaskEnv, id).Delete(&models.DataRelation{}).Error; err != nil { + return err + } + // Delete the env var + if err := tx.Where("id = ?", id).Delete(&models.EnvironmentVariable{}).Error; err != nil { + return err + } + return nil + }) + if err == nil { + relation.DataRelation.CleanRelations(id, constant.RelationTypeEnvTag) + return true, nil + } + return false, nil + } + + result := database.DB.Where("id = ?", id).Delete(&models.EnvironmentVariable{}) + if result.RowsAffected > 0 { + relation.DataRelation.CleanRelations(id, constant.RelationTypeEnvTag) + return true, nil + } + return false, nil +} + +// GetEnvVarsByIDs 根据逗号分隔的ID字符串获取环境变量列表,返回 NAME=VALUE 格式 +// 如果存在重名变量,会类似青龙面板一样使用 & 拼接 +func (es *EnvService) GetEnvVarsByIDs(envIDs string) []string { + if envIDs == "" { + return nil + } + + ids := splitEnvIDs(envIDs) + var envs []models.EnvironmentVariable + for _, id := range ids { + env := es.GetEnvVarByID(id) + if env != nil { + envs = append(envs, *env) + } + } + + return es.formatEnvVars(envs) +} + +// GetEnvVarsAndSecretsByIDs 根据逗号分隔的ID字符串获取环境变量列表和安全机密值列表 +func (es *EnvService) GetEnvVarsAndSecretsByIDs(envIDs string) ([]string, []string) { + if envIDs == "" { + return nil, nil + } + + ids := splitEnvIDs(envIDs) + var envs []models.EnvironmentVariable + for _, id := range ids { + env := es.GetEnvVarByID(id) + if env != nil { + envs = append(envs, *env) + } + } + + return es.formatEnvVarsAndSecrets(envs) +} + +// GetAllEnvVars获取系统中所有的环境变量,并按 NAME=VALUE 格式返回 +func (es *EnvService) GetAllEnvVars() []string { + var envs []models.EnvironmentVariable + if err := database.DB.Find(&envs).Error; err != nil { + return nil + } + return es.formatEnvVars(envs) +} + +// GetAllEnvVarsAndSecrets 获取系统中所有的环境变量和安全机密值列表 +func (es *EnvService) GetAllEnvVarsAndSecrets() ([]string, []string) { + var envs []models.EnvironmentVariable + if err := database.DB.Find(&envs).Error; err != nil { + return nil, nil + } + return es.formatEnvVarsAndSecrets(envs) +} + +// formatEnvVars 将环境变量列表格式化为 NAME=VALUE 数组,并处理重名合并 (过滤掉所有的 Secret) +func (es *EnvService) formatEnvVars(envs []models.EnvironmentVariable) []string { + if len(envs) == 0 { + return nil + } + + type mergedEnv struct { + name string + values []string + } + var mergedList []mergedEnv + nameToIndex := make(map[string]int) + + for _, env := range envs { + // 非调度器入口,直接当做没有(跳过 Secret) + if env.Type == constant.EnvTypeSecret { + continue + } + + value := string(env.Value) + if !utils.DerefBool(env.Enabled, true) { + value = "" + } + + if idx, ok := nameToIndex[env.Name]; ok { + mergedList[idx].values = append(mergedList[idx].values, value) + } else { + nameToIndex[env.Name] = len(mergedList) + mergedList = append(mergedList, mergedEnv{ + name: env.Name, + values: []string{value}, + }) + } + } + + var result []string + for _, item := range mergedList { + val := strings.Join(item.values, "&") + result = append(result, item.name+"="+val) + } + return result +} + +// formatEnvVarsAndSecrets 将环境变量列表格式化为 NAME=VALUE 数组,并提取明文安全机密列表 +func (es *EnvService) formatEnvVarsAndSecrets(envs []models.EnvironmentVariable) ([]string, []string) { + if len(envs) == 0 { + return nil, nil + } + + type mergedEnv struct { + name string + values []string + } + var mergedList []mergedEnv + var secrets []string + nameToIndex := make(map[string]int) + + for _, env := range envs { + value := string(env.Value) + if env.Type == constant.EnvTypeSecret { + if decValue, err := utils.Decrypt(value); err == nil { + value = decValue + if utils.DerefBool(env.Enabled, true) && value != "" { + secrets = append(secrets, value) + } + } + } + + if !utils.DerefBool(env.Enabled, true) { + value = "" + } + + if idx, ok := nameToIndex[env.Name]; ok { + mergedList[idx].values = append(mergedList[idx].values, value) + } else { + nameToIndex[env.Name] = len(mergedList) + mergedList = append(mergedList, mergedEnv{ + name: env.Name, + values: []string{value}, + }) + } + } + + var result []string + for _, item := range mergedList { + // 多个值使用 & 拼接 + val := strings.Join(item.values, "&") + result = append(result, item.name+"="+val) + } + return result, secrets +} + +// splitEnvIDs 解析逗号分隔的ID字符串 +func splitEnvIDs(envIDs string) []string { + var ids []string + for _, s := range strings.Split(envIDs, ",") { + s = strings.TrimSpace(s) + if s != "" { + ids = append(ids, s) + } + } + return ids +} + +// SaveEnvTags 保存环境变量标签 +func (es *EnvService) SaveEnvTags(envID string, tagsStr string) { + database.DB.Where("data_id = ? AND type = ?", envID, constant.RelationTypeEnvTag).Delete(&models.DataRelation{}) + if tagsStr == "" { + return + } + tags := strings.Split(tagsStr, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + var storage models.DataStorage + res := database.DB.Where("type = ? AND name = ?", constant.RelationTypeEnvTag, tag).Limit(1).Find(&storage) + if res.RowsAffected == 0 { + storage = models.DataStorage{ + ID: utils.GenerateID(), + Type: constant.RelationTypeEnvTag, + Name: tag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + database.DB.Create(&storage) + } + relation := models.DataRelation{ + ID: utils.GenerateID(), + DataID: envID, + RelateID: storage.ID, + Type: constant.RelationTypeEnvTag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + database.DB.Create(&relation) + } +} + +// LoadEnvTags 为环境变量列表加载标签 +func (es *EnvService) LoadEnvTags(envs []models.EnvironmentVariable) { + if len(envs) == 0 { + return + } + envIDs := make([]string, len(envs)) + for i, e := range envs { + envIDs[i] = e.ID + } + + tagsMap := relation.DataRelation.LoadTags(envIDs, constant.RelationTypeEnvTag) + + for i, e := range envs { + if tags, ok := tagsMap[e.ID]; ok { + envs[i].Tags = strings.Join(tags, ",") + } else { + envs[i].Tags = "" + } + } +} + +// GetAllEnvTags 获取全局环境变量标签 +func (es *EnvService) GetAllEnvTags() ([]string, error) { + return relation.DataRelation.GetAllTags(constant.RelationTypeEnvTag) +} + +// CleanEnvTags 删除环境变量时清理关联标签记录 +func (es *EnvService) CleanEnvTags(id string) { + database.DB.Where("data_id = ? AND type = ?", id, constant.RelationTypeEnvTag).Delete(&models.DataRelation{}) +} + diff --git a/internal/services/init_service.go b/internal/services/init_service.go new file mode 100644 index 0000000..cc4547f --- /dev/null +++ b/internal/services/init_service.go @@ -0,0 +1,66 @@ +package services + +import ( + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/utils" +) + +type InitService struct { + settingsService *SettingsService +} + +func NewInitService(settingsService *SettingsService) *InitService { + return &InitService{ + settingsService: settingsService, + } +} + +// Initialize 执行系统初始化,返回 UserService +func (s *InitService) Initialize() *UserService { + logger.Info("[Initialize] 开始初始化系统...") + + // 初始化默认设置 + if err := s.settingsService.InitSettings(); err != nil { + logger.Warnf("[Initialize] 初始化设置失败: %v", err) + } + + // 创建 UserService + userService := NewUserService() + + // 创建管理员账号 + s.initializeAdmin(userService) + + // 初始化语言环境 + s.initializeLanguages() + + return userService +} + +// initializeLanguages 初始化同步语言环境 +func (s *InitService) initializeLanguages() { + logger.Info("[Languages] 开始初始化编程语言环境...") + miseService := NewMiseService() + if err := miseService.Sync(); err != nil { + logger.Errorf("[Languages] 初始化同步语言环境失败: %v", err) + } else { + logger.Info("[Languages] 初始化语言环境同步完成") + } +} + +// initializeAdmin 创建管理员账号 +func (s *InitService) initializeAdmin(userService *UserService) { + existingUser := userService.GetUserByUsername("admin") + if existingUser != nil { + logger.Info("[Init] 管理员账号已存在,跳过创建") + return + } + + password := utils.RandomString(12) + userService.CreateUser("admin", password, "admin@local", "admin") + logger.Infof("--------------------------------------------------") + logger.Infof("[Init] 管理员账号创建成功:") + logger.Infof("[Init] 用户名: admin") + logger.Infof("[Init] 密 码: %s", password) + logger.Infof("[Init] 请妥善保管您的密码,并登录后及时修改。") + logger.Infof("--------------------------------------------------") +} diff --git a/internal/services/install_service.go b/internal/services/install_service.go new file mode 100644 index 0000000..d211748 --- /dev/null +++ b/internal/services/install_service.go @@ -0,0 +1,263 @@ +package services + +import ( + "os" + "path/filepath" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/utils" + + "gopkg.in/ini.v1" +) + +// InstallRequest 安装请求 +type InstallRequest struct { + // 数据库配置 + DBType string `json:"db_type" binding:"required"` // sqlite 或 mysql + DBHost string `json:"db_host"` // MySQL 主机 + DBPort int `json:"db_port"` // MySQL 端口 + DBUser string `json:"db_user"` // MySQL 用户名 + DBPassword string `json:"db_password"` // MySQL 密码 + DBName string `json:"db_name"` // MySQL 数据库名 + DBPath string `json:"db_path"` // SQLite 数据库路径 + DBSSLMode string `json:"db_ssl_mode"` // SSL 模式 + + // Redis 配置(可选) + RedisEnabled bool `json:"redis_enabled"` // 是否启用 Redis + RedisHost string `json:"redis_host"` // Redis 主机 + RedisPort int `json:"redis_port"` // Redis 端口 + RedisPassword string `json:"redis_password"` // Redis 密码 + RedisDB int `json:"redis_db"` // Redis 数据库索引 + + // 管理员账号 + AdminUsername string `json:"admin_username" binding:"required"` // 管理员用户名 + AdminPassword string `json:"admin_password" binding:"required"` // 管理员密码 + AdminEmail string `json:"admin_email"` // 管理员邮箱 + + // 站点设置 + SiteTitle string `json:"site_title"` // 站点标题 + SiteSubtitle string `json:"site_subtitle"` // 站点副标题 +} + +// InstallStatus 安装状态 +type InstallStatus struct { + Installed bool `json:"installed"` + ConfigPath string `json:"config_path"` + DBType string `json:"db_type"` +} + +// InstallService 安装服务 +type InstallService struct { + settingsService *SettingsService + userService *UserService +} + +// NewInstallService 创建安装服务 +func NewInstallService() *InstallService { + return &InstallService{ + settingsService: NewSettingsService(), + userService: NewUserService(), + } +} + +// CheckInstallStatus 检查安装状态 +func (s *InstallService) CheckInstallStatus() (*InstallStatus, error) { + status := &InstallStatus{ + ConfigPath: constant.ConfigPath, + } + + // 检查配置文件是否存在 + configExists := false + if _, err := os.Stat(constant.ConfigPath); err == nil { + configExists = true + } + + // 检查是否已初始化(数据库中有管理员用户) + initialized := false + if configExists { + // 尝试检查是否有管理员用户 + admin := s.userService.GetUserByUsername("admin") + if admin != nil { + initialized = true + } + } + + // 同时检查数据库中的初始化标志 + dbInitialized := s.settingsService.Get(constant.SectionSystem, constant.KeyInitialized) + if dbInitialized == "true" { + initialized = true + } + + status.Installed = initialized + status.DBType = Config.Database.Type + + return status, nil +} + +// Install 执行安装 +func (s *InstallService) Install(req *InstallRequest) error { + // 1. 创建配置文件 + if err := s.createConfigFile(req); err != nil { + return err + } + + // 2. 重新加载数据库配置 + if err := s.reloadDatabase(req); err != nil { + return err + } + + // 3. 初始化数据库 + if err := database.Migrate(); err != nil { + return err + } + + // 4. 初始化设置 + if err := s.settingsService.InitSettings(); err != nil { + logger.Warnf("[Install] 初始化设置失败: %v", err) + } + + // 5. 创建管理员账号 + if err := s.createAdmin(req); err != nil { + return err + } + + // 6. 保存站点设置 + if req.SiteTitle != "" || req.SiteSubtitle != "" { + siteSettings := make(map[string]string) + if req.SiteTitle != "" { + siteSettings[constant.KeyTitle] = req.SiteTitle + } + if req.SiteSubtitle != "" { + siteSettings[constant.KeySubtitle] = req.SiteSubtitle + } + if err := s.settingsService.SetSection(constant.SectionSite, siteSettings); err != nil { + logger.Warnf("[Install] 保存站点设置失败: %v", err) + } + } + + // 7. 标记已初始化 + if err := s.settingsService.Set(constant.SectionSystem, constant.KeyInitialized, "true"); err != nil { + return err + } + + logger.Info("[Install] 安装完成") + return nil +} + +// createConfigFile 创建配置文件 +func (s *InstallService) createConfigFile(req *InstallRequest) error { + // 确保配置目录存在 + configDir := filepath.Dir(constant.ConfigPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + // 创建配置文件 + cfg := ini.Empty() + + // [server] 配置 + serverSection, _ := cfg.NewSection("server") + serverSection.NewKey("port", "8052") + serverSection.NewKey("host", "0.0.0.0") + serverSection.NewKey("cookie_name", "BHToken") + + // [database] 配置 + dbSection, _ := cfg.NewSection("database") + dbSection.NewKey("type", req.DBType) + + if req.DBType == "sqlite" { + dbPath := req.DBPath + if dbPath == "" { + dbPath = constant.DefaultDBPath + } + dbSection.NewKey("path", dbPath) + } else if req.DBType == "mysql" { + dbSection.NewKey("host", req.DBHost) + dbSection.NewKey("port", intToStr(req.DBPort)) + dbSection.NewKey("user", req.DBUser) + dbSection.NewKey("password", req.DBPassword) + dbSection.NewKey("dbname", req.DBName) + if req.DBSSLMode != "" { + dbSection.NewKey("ssl_mode", req.DBSSLMode) + } + } + dbSection.NewKey("table_prefix", "taskpool_") + + // [redis] 配置(如果启用) + if req.RedisEnabled { + redisSection, _ := cfg.NewSection("redis") + redisSection.NewKey("enabled", "true") + redisSection.NewKey("host", req.RedisHost) + redisSection.NewKey("port", intToStr(req.RedisPort)) + if req.RedisPassword != "" { + redisSection.NewKey("password", req.RedisPassword) + } + redisSection.NewKey("db", intToStr(req.RedisDB)) + } + + // [security] 配置 + securitySection, _ := cfg.NewSection("security") + secret := utils.RandomString(32) + securitySection.NewKey("secret", secret) + + // 保存配置文件 + if err := cfg.SaveTo(constant.ConfigPath); err != nil { + return err + } + + logger.Infof("[Install] 配置文件已保存到: %s", constant.ConfigPath) + return nil +} + +// reloadDatabase 重新加载数据库 +func (s *InstallService) reloadDatabase(req *InstallRequest) error { + dbCfg := &database.Config{ + Type: req.DBType, + Host: req.DBHost, + Port: req.DBPort, + User: req.DBUser, + Password: req.DBPassword, + DBName: req.DBName, + Path: req.DBPath, + SSLMode: req.DBSSLMode, + } + + if req.DBType == "sqlite" && req.DBPath == "" { + dbCfg.Path = constant.DefaultDBPath + } + + if err := database.Init(dbCfg); err != nil { + return err + } + + return nil +} + +// createAdmin 创建管理员账号 +func (s *InstallService) createAdmin(req *InstallRequest) error { + // 检查用户是否已存在 + existingUser := s.userService.GetUserByUsername(req.AdminUsername) + if existingUser != nil { + logger.Info("[Install] 管理员账号已存在,跳过创建") + return nil + } + + email := req.AdminEmail + if email == "" { + email = "admin@local" + } + + s.userService.CreateUser(req.AdminUsername, req.AdminPassword, email, "admin") + logger.Infof("[Install] 管理员账号创建成功: %s", req.AdminUsername) + return nil +} + +// intToStr 整数转字符串 +func intToStr(n int) string { + if n == 0 { + return "" + } + return utils.IntToStr(n) +} diff --git a/internal/services/interconnect_service.go b/internal/services/interconnect_service.go new file mode 100644 index 0000000..6c0481c --- /dev/null +++ b/internal/services/interconnect_service.go @@ -0,0 +1,85 @@ +package services + +import ( + "strings" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +type InterconnectService struct{} + +func NewInterconnectService() *InterconnectService { + return &InterconnectService{} +} + +func (s *InterconnectService) GetNodes() ([]*models.InterconnectNode, error) { + var nodes []*models.InterconnectNode + err := database.DB.Find(&nodes).Error + return nodes, err +} + +func (s *InterconnectService) GetNodeByID(id string) (*models.InterconnectNode, error) { + var node models.InterconnectNode + err := database.DB.Where("id = ?", id).First(&node).Error + return &node, err +} + +func (s *InterconnectService) CreateNode(name, url, token, remark string) (*models.InterconnectNode, error) { + nodeID := utils.GenerateID() + if url == "" { + url = "tunnel://" + nodeID + } + node := &models.InterconnectNode{ + ID: nodeID, + Name: name, + URL: url, + Token: strings.ToLower(token), + Remark: remark, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + err := database.DB.Create(node).Error + return node, err +} + +func (s *InterconnectService) UpdateNode(id, name, url, token, remark string) (*models.InterconnectNode, error) { + node, err := s.GetNodeByID(id) + if err != nil { + return nil, err + } + node.Name = name + if url != "" { + node.URL = url + } + node.Token = token + node.Remark = remark + node.UpdatedAt = models.Now() + + err = database.DB.Save(node).Error + return node, err +} + +func (s *InterconnectService) DeleteNode(id string) error { + return database.DB.Where("id = ?", id).Delete(&models.InterconnectNode{}).Error +} + +func (s *InterconnectService) GetNodeByToken(token string) (*models.InterconnectNode, error) { + var node models.InterconnectNode + err := database.DB.Where("token = ?", token).First(&node).Error + return &node, err +} + +func (s *InterconnectService) UpdateNodeMonitorData(id string, metrics models.NodeMetrics) error { + now := models.Now() + return database.DB.Model(&models.InterconnectNode{}). + Where("id = ?", id). + Select("status", "metrics", "last_heartbeat_at", "updated_at"). + Updates(models.InterconnectNode{ + Status: "online", + Metrics: metrics, + LastHeartbeatAt: &now, + UpdatedAt: now, + }).Error +} diff --git a/internal/services/login_log_service.go b/internal/services/login_log_service.go new file mode 100644 index 0000000..26a5a3a --- /dev/null +++ b/internal/services/login_log_service.go @@ -0,0 +1,128 @@ +package services + +import ( + "fmt" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +type LoginLogService struct{} + +func NewLoginLogService() *LoginLogService { + return &LoginLogService{} +} + +// Create 创建登录日志 +func (s *LoginLogService) Create(username, ip, userAgent, status, message string) error { + level := constant.LogLevelInfo + if status != "success" { + level = constant.LogLevelWarning + } + log := &models.AppLog{ + ID: utils.GenerateID(), + Category: constant.LogCategoryLoginLog, + Title: username, + Content: models.BigText(userAgent), + Level: level, + Status: status, + RefID: ip, + ErrorMsg: models.BigText(message), + } + err := database.DB.Create(log).Error + if err == nil { + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventAppLogAdded, + Payload: log, + }) + } + return err +} + +// SubscribeEvents 注册订阅事件 +func (s *LoginLogService) SubscribeEvents(bus *eventbus.EventBus) { + // 用户登录事件 + bus.Subscribe(constant.EventUserLogin, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + + username, _ := payload["username"].(string) + ip, _ := payload["ip"].(string) + userAgent, _ := payload["userAgent"].(string) + status, _ := payload["status"].(string) + message, _ := payload["message"].(string) + + s.Create(username, ip, userAgent, status, message) + + // 如果登录成功,触发系统通知 + if status == "success" { + bus.Publish(eventbus.Event{ + Type: constant.EventSystemNotice, + Payload: map[string]interface{}{ + "title": "登录提醒", + "content": fmt.Sprintf("用户 %s 已从 IP %s 登录系统", username, ip), + "level": constant.LogLevelWarning, + }, + }) + } + }) + + // 暴力破解防御触发事件 + bus.Subscribe(constant.EventBruteForceLogin, func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + + username, _ := payload["username"].(string) + ip, _ := payload["ip"].(string) + userAgent, _ := payload["userAgent"].(string) + + s.Create(username, ip, userAgent, "failed", "尝试次数过多,由于暴力破解防御机制已锁定") + + // 触发系统通知 + bus.Publish(eventbus.Event{ + Type: constant.EventSystemNotice, + Payload: map[string]interface{}{ + "title": "系统安全警告", + "content": fmt.Sprintf("检测到 IP %s 正在尝试暴力破解用户 %s", ip, username), + "level": constant.LogLevelError, + }, + }) + }) +} + +// List 获取登录日志列表 +func (s *LoginLogService) List(page, pageSize int, username string) ([]models.AppLog, int64, error) { + var logs []models.AppLog + var total int64 + + query := database.DB.Model(&models.AppLog{}).Where("category = ?", constant.LogCategoryLoginLog) + if username != "" { + query = query.Where("title LIKE ?", "%"+username+"%") + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// CleanOldLogs 清理指定天数前的日志 +func (s *LoginLogService) CleanOldLogs(days int) (int64, error) { + deadline := time.Now().AddDate(0, 0, -days) + result := database.DB.Unscoped().Where("category = ? AND created_at < ?", constant.LogCategoryLoginLog, deadline).Delete(&models.AppLog{}) + return result.RowsAffected, result.Error +} diff --git a/internal/services/migration_v3.go b/internal/services/migration_v3.go new file mode 100644 index 0000000..c225fe0 --- /dev/null +++ b/internal/services/migration_v3.go @@ -0,0 +1,446 @@ +package services + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" + "gorm.io/gorm" +) + +// MigrationTable 定义迁移配置 +type MigrationTable struct { + Model any + EntityName string + FKs map[string]string // 单个外键映射: 字段名 -> 实体名 + MultiFKs map[string]string // 复合外键映射 (如逗号分隔): 字段名 -> 实体名 +} + +func getMigrationTables() []MigrationTable { + return []MigrationTable{ + {&models.User{}, "users", nil, nil}, + {&models.Agent{}, "agents", nil, nil}, + {&models.AgentToken{}, "tokens", nil, nil}, + {&models.EnvironmentVariable{}, "envs", map[string]string{"UserID": "users"}, nil}, + {&models.Task{}, "tasks", map[string]string{"AgentID": "agents"}, map[string]string{"Envs": "envs"}}, + {&models.TaskLog{}, "task_logs", map[string]string{"TaskID": "tasks", "AgentID": "agents"}, nil}, + {&models.Script{}, "scripts", map[string]string{"UserID": "users"}, nil}, + {&models.Setting{}, "settings", nil, nil}, + {&models.SendStats{}, "send_stats", map[string]string{"TaskID": "tasks"}, nil}, + {&models.Language{}, "languages", nil, nil}, + {&models.Dependency{}, "deps", nil, nil}, + } +} + +func getTableName(db *gorm.DB, model any) string { + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(model); err != nil { + return "" + } + return stmt.Schema.Table +} + +func isTableStringID(db *gorm.DB, model any) bool { + if !db.Migrator().HasTable(model) { + return true + } + columnTypes, err := db.Migrator().ColumnTypes(model) + if err != nil { + return true + } + for _, ct := range columnTypes { + if strings.ToLower(ct.Name()) == "id" { + typeName := strings.ToLower(ct.DatabaseTypeName()) + return strings.Contains(typeName, "char") || strings.Contains(typeName, "text") || strings.Contains(typeName, "string") + } + } + return false +} + +func getValFromMap(m map[string]interface{}, key string) (interface{}, bool) { + lowerKey := strings.ToLower(key) + for k, v := range m { + if strings.ToLower(k) == lowerKey { + return v, true + } + } + return nil, false +} + +func RunMigrationV3() error { + db := database.DB + if db == nil { + return fmt.Errorf("数据库未初始化") + } + + // 0. 检查迁移标记,防止重复迁移逻辑被误判触发 + if db.Migrator().HasTable(&models.Setting{}) { + var migrationFlag models.Setting + res := db.Where(&models.Setting{Section: "system", Key: "migration_v3_success"}).Limit(1).Find(&migrationFlag) + if res.Error == nil && res.RowsAffected > 0 && migrationFlag.Value == "true" { + // 如果已经是字符串 ID 模式,双重确认 + logger.Info("[MigrationV3] 系统已处于 V3 模式,跳过检查") + return nil + } + } + + tables := getMigrationTables() + needMigration := false + for _, t := range tables { + if !isTableStringID(db, t.Model) { + needMigration = true + logger.Infof("[MigrationV3] 表 [%s] ID 为数字,需迁移", getTableName(db, t.Model)) + break + } + } + + if !needMigration { + // 没有数字 ID 表了,但也可能还没打标(比如之前手动修过表),此时补一个标 + return markMigrationSuccess(db) + } + + // 1. 备份过程 + backupDir := "./data" + os.MkdirAll(backupDir, 0755) + // 如果已经有还原过的记录,为了防止反复循环,我们可以检查还原标记 + backups, _ := filepath.Glob(filepath.Join(backupDir, "migration_v3_backup_*.zip")) + if len(backups) == 0 { + logger.Infof("[MigrationV3] 执行关键备份...") + backupService := NewBackupService() + zipPath, err := backupService.CreateBackup() + if err != nil { + return fmt.Errorf("自动备份失败,流程终止: %v", err) + } + baseName := strings.TrimSuffix(filepath.Base(zipPath), ".zip") + newPath := filepath.Join(backupDir, fmt.Sprintf("migration_v3_backup_%s.zip", baseName)) + os.Rename(zipPath, newPath) + logger.Infof("[MigrationV3] 备份成功: %s", newPath) + } + + mappings := make(map[string]map[uint]string) + // 根据数据库类型决定是否使用事务: + // - PostgreSQL: DDL 完全支持事务,使用事务保证原子性 + // - MySQL: DDL 会隐式提交事务,包裹事务无意义 + // - SQLite: DDL+DML 混合在 GORM 事务中会导致数据丢失 + dbType := db.Dialector.Name() + if dbType == "postgres" { + err := db.Transaction(func(tx *gorm.DB) error { + return performHardMigration(tx, mappings) + }) + if err != nil { + return err + } + } else { + // SQLite / MySQL: 不使用事务包裹 + if err := performHardMigration(db, mappings); err != nil { + return err + } + } + + // 3. 标记成功 + return markMigrationSuccess(db) +} + +func markMigrationSuccess(db *gorm.DB) error { + if !db.Migrator().HasTable(&models.Setting{}) { + return nil + } + var flag models.Setting + res := db.Where(&models.Setting{Section: "system", Key: "migration_v3_success"}).Limit(1).Find(&flag) + if res.Error != nil || res.RowsAffected == 0 { + // 创建或更新 + flag = models.Setting{ + ID: utils.GenerateID(), + Section: "system", + Key: "migration_v3_success", + Value: "true", + } + return db.Create(&flag).Error + } + return db.Model(&flag).Update("value", "true").Error +} + +func performHardMigration(db *gorm.DB, mappings map[string]map[uint]string) error { + allTables := getMigrationTables() + + // --------------------------------------------------------- + // 第一阶段:全量构建 ID 映射映射表 (Pass 1) + // --------------------------------------------------------- + for _, t := range allTables { + actualName := getTableName(db, t.Model) + if actualName == "" || !db.Migrator().HasTable(actualName) { + continue + } + mappings[t.EntityName] = make(map[uint]string) + oldTableName := actualName + "_v2_bak" + + // 如果还没有备份表,说明这是第一次处理该表,先重命名 + if !db.Migrator().HasTable(oldTableName) { + if isTableStringID(db, t.Model) { + continue // 已经是字符串 ID 且无备份,跳过 + } + if err := db.Migrator().RenameTable(actualName, oldTableName); err != nil { + return fmt.Errorf("重命名表 %s 失败: %v", actualName, err) + } + // SQLite 重命名表后,索引名称不变,会导致 AutoMigrate 创建新表时索引冲突 + // 需要先删除备份表上的旧索引 + dropOldIndexes(db, oldTableName) + } + + // 预先为该表所有记录生成新的 xid + var rows []map[string]interface{} + db.Table(oldTableName).Select("id").Find(&rows) + for _, row := range rows { + if val, ok := getValFromMap(row, "id"); ok { + uid := parseUint(val) + if uid > 0 { + mappings[t.EntityName][uid] = utils.GenerateID() + } + } + } + logger.Infof("[MigrationV3] Pass 1: 构建关键表 %s 的 ID 映射, 共 %d 条", actualName, len(mappings[t.EntityName])) + } + + // 构建 fallback ID:FK 映射找不到时使用第一条记录的新 ID + fallbackIDs := make(map[string]string) + for _, t := range allTables { + // 优先从 mappings 中取第一个新 xid(本次迁移生成的) + if m, ok := mappings[t.EntityName]; ok && len(m) > 0 { + for _, newID := range m { + fallbackIDs[t.EntityName] = newID + break + } + continue + } + // 如果该表已经是 string ID(被跳过了),从实际表查第一条 + actualName := getTableName(db, t.Model) + if actualName != "" && db.Migrator().HasTable(actualName) { + var row map[string]interface{} + if err := db.Table(actualName).Select("id").Order("id").Limit(1).Find(&row).Error; err == nil { + if id, ok := getValFromMap(row, "id"); ok && id != nil { + fallbackIDs[t.EntityName] = fmt.Sprintf("%v", id) + } + } + } + } + + // --------------------------------------------------------- + // 第二、三阶段:正式转换数据并处理关联字段 (Pass 2 & 3) + // --------------------------------------------------------- + for _, t := range allTables { + actualName := getTableName(db, t.Model) + oldTableName := actualName + "_v2_bak" + + if !db.Migrator().HasTable(oldTableName) { + // 虽然可能已经改过格式,但为了安全还是 AutoMigrate 一下 + db.AutoMigrate(t.Model) + continue + } + + logger.Infof("[MigrationV3] Pass 2&3: 正在转换数据并修复关联: %s", actualName) + db.AutoMigrate(t.Model) + + // 获取新表的有效列名(小写) + columnTypes, _ := db.Migrator().ColumnTypes(t.Model) + validColumns := make(map[string]bool) + for _, ct := range columnTypes { + validColumns[strings.ToLower(ct.Name())] = true + } + + var oldData []map[string]interface{} + if err := db.Table(oldTableName).Find(&oldData).Error; err != nil { + return err + } + + for _, row := range oldData { + // 1. 处理主键 ID + if val, ok := getValFromMap(row, "id"); ok { + uid := parseUint(val) + if nid, exists := mappings[t.EntityName][uid]; exists { + row["id"] = nid + } else { + row["id"] = utils.GenerateID() + } + } + + // 2. 处理单外键关联 (Phase 3) + for field, parentEntity := range t.FKs { + columnName := getColumnName(field) + if val, ok := getValFromMap(row, columnName); ok && val != nil { + strVal := fmt.Sprintf("%v", val) + if strVal == "" || strVal == "0" || strVal == "" { + row[columnName] = nil + continue + } + // 如果值已经是非数字字符串(例如已迁移过的 xid),直接保留 + if !utils.IsNumeric(strVal) { + continue + } + // 数字型外键,查映射表转换 + ufk := parseUint(val) + if ufk > 0 { + if nid, exists := mappings[parentEntity][ufk]; exists { + row[columnName] = nid + } else if fbID, hasFB := fallbackIDs[parentEntity]; hasFB { + logger.Warnf("[MigrationV3] 表 %s 的 %s=%d 在 %s 映射中未找到,使用首条记录 ID: %s", getTableName(db, t.Model), columnName, ufk, parentEntity, fbID) + row[columnName] = fbID + } else { + logger.Warnf("[MigrationV3] 表 %s 的 %s=%d 在 %s 映射中未找到且无 fallback,置空", getTableName(db, t.Model), columnName, ufk, parentEntity) + row[columnName] = nil + } + } else { + row[columnName] = nil + } + } + } + + // 3. 处理复合多外键字段 (envs) + for field, parentEntity := range t.MultiFKs { + columnName := getColumnName(field) + if val, ok := getValFromMap(row, columnName); ok && val != nil { + if strVal, ok := val.(string); ok && strVal != "" { + row[columnName] = transformMultiIDs(strVal, parentEntity, mappings) + } + } + } + + // 4. 过滤不存在的列并插入 + filteredRow := make(map[string]interface{}) + for k, v := range row { + if validColumns[strings.ToLower(k)] { + filteredRow[k] = v + } + } + if err := db.Table(actualName).Create(filteredRow).Error; err != nil { + return err + } + } + + // 迁移完成,清理备份表 + db.Migrator().DropTable(oldTableName) + } + return nil +} + +// 辅助函数:解析各种数字 ID +func parseUint(val interface{}) uint { + if val == nil { + return 0 + } + switch v := val.(type) { + case uint: + return v + case int64: + return uint(v) + case int: + return uint(v) + case uint64: + return uint(v) + case float64: + return uint(v) + case string: + var u uint + fmt.Sscanf(v, "%d", &u) + return u + } + return 0 +} + +// 辅助函数:字段名转列名 +func getColumnName(field string) string { + switch field { + case "AgentID": + return "agent_id" + case "TaskID": + return "task_id" + case "UserID": + return "user_id" + case "LogID": + return "log_id" + case "Envs": + return "envs" + default: + return strings.ToLower(field) + } +} + +// 辅助函数:处理逗号分隔的 ID 列表 +func transformMultiIDs(oldStr string, parentEntity string, mappings map[string]map[uint]string) string { + parts := strings.Split(oldStr, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if len(p) == 20 && !utils.IsNumeric(p) { + result = append(result, p) // 已经是 xid,保留 + continue + } + uid := parseUint(p) + if uid > 0 { + if nid, exists := mappings[parentEntity][uid]; exists { + result = append(result, nid) + } + } + } + return strings.Join(result, ",") +} + +// dropOldIndexes 删除备份表上的旧索引,防止 AutoMigrate 创建新表时索引名冲突 +// SQLite 重命名表后索引名不变,MySQL/PostgreSQL 也可能存在类似问题 +func dropOldIndexes(db *gorm.DB, tableName string) { + dbType := db.Dialector.Name() + var indexNames []string + + switch dbType { + case "sqlite": + var indexes []struct{ Name string } + db.Raw("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'", tableName).Scan(&indexes) + for _, idx := range indexes { + indexNames = append(indexNames, idx.Name) + } + case "mysql": + var indexes []struct { + KeyName string `gorm:"column:Key_name"` + } + db.Raw("SHOW INDEX FROM `" + tableName + "`").Scan(&indexes) + seen := make(map[string]bool) + for _, idx := range indexes { + if idx.KeyName != "PRIMARY" && !seen[idx.KeyName] { + indexNames = append(indexNames, idx.KeyName) + seen[idx.KeyName] = true + } + } + case "postgres": + var indexes []struct { + IndexName string `gorm:"column:indexname"` + } + db.Raw("SELECT indexname FROM pg_indexes WHERE tablename=?", tableName).Scan(&indexes) + for _, idx := range indexes { + if !strings.HasSuffix(idx.IndexName, "_pkey") { + indexNames = append(indexNames, idx.IndexName) + } + } + } + + for _, name := range indexNames { + var dropSQL string + switch dbType { + case "mysql": + dropSQL = fmt.Sprintf("DROP INDEX `%s` ON `%s`", name, tableName) + default: + dropSQL = fmt.Sprintf("DROP INDEX IF EXISTS \"%s\"", name) + } + if err := db.Exec(dropSQL).Error; err != nil { + logger.Warnf("[MigrationV3] 删除旧索引 %s 失败 (可忽略): %v", name, err) + } else { + logger.Infof("[MigrationV3] 已删除备份表旧索引: %s", name) + } + } +} diff --git a/internal/services/mise_service.go b/internal/services/mise_service.go new file mode 100644 index 0000000..49d4c1a --- /dev/null +++ b/internal/services/mise_service.go @@ -0,0 +1,405 @@ +package services + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/deps" + "github.com/engigu/taskpool/internal/utils" + "gorm.io/gorm" +) + +type MiseService struct{} + +func NewMiseService() *MiseService { + return &MiseService{} +} + +type MiseLanguage struct { + Plugin string `json:"plugin"` + Version string `json:"version"` + Source MiseSource `json:"source"` + IsGlobal bool `json:"is_global"` + InstallPath string `json:"install_path,omitempty"` + InstalledAt string `json:"installed_at,omitempty"` // 安装日期 +} + +type MiseSource struct { + Type string `json:"type"` + Path string `json:"path"` +} + +// List 实时从系统检测 mise 环境并同步到数据库 +func (s *MiseService) List() ([]MiseLanguage, error) { + langs, err := s.fetchLiveLanguages() + if err != nil { + return nil, err + } + + // 异步同步到数据库,确保列表响应速度 + go s.syncToDB(langs) + + return langs, nil +} + +// Sync 实时检测本地 mise 环境并同步到数据库 +func (s *MiseService) Sync() error { + // 获取实时数据 + langs, err := s.fetchLiveLanguages() + if err != nil { + return err + } + + // 同步到数据库 + s.syncToDB(langs) + return nil +} + +// fetchLiveLanguages 实时从系统检测 mise 语言列表 +func (s *MiseService) fetchLiveLanguages() ([]MiseLanguage, error) { + // 使用 --json 获取格式化数据 + cmd := exec.Command("mise", "ls", "--json") + // 继承父进程环境变量 + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "MISE_NO_COLOR=1", "TERM=dumb") + + output, err := cmd.Output() + if err != nil { + logger.Warnf("[Mise] mise ls --json failed: %v", err) + return s.listFallback() + } + + // 1. 尝试解析为数组格式 [{}, {}] + var languages []MiseLanguage + if err := json.Unmarshal(output, &languages); err == nil { + s.enrichInstallDates(languages) + s.enrichSourceInfo(languages) + s.sortByInstallDate(languages) + return languages, nil + } + + // 2. 如果失败,尝试解析为对象格式 {"node": [{}], "python": [{}]} + var langMap map[string][]MiseLanguage + if err := json.Unmarshal(output, &langMap); err == nil { + var result []MiseLanguage + for plugin, items := range langMap { + for _, item := range items { + // 填充插件名称(对象格式中插件名通常是 key) + if item.Plugin == "" { + item.Plugin = plugin + } + result = append(result, item) + } + } + s.enrichInstallDates(result) + s.enrichSourceInfo(result) + s.sortByInstallDate(result) + return result, nil + } + + return s.listFallback() +} + +func (s *MiseService) listFallback() ([]MiseLanguage, error) { + cmd := exec.Command("mise", "ls") + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "MISE_NO_COLOR=1", "TERM=dumb") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("mise ls failed: %v, output: %s", err, string(output)) + } + + lines := strings.Split(string(output), "\n") + languages := []MiseLanguage{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(strings.ToLower(line), "tool") { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + lang := MiseLanguage{ + Plugin: parts[0], + Version: parts[1], + } + if len(parts) >= 3 { + lang.Source = MiseSource{Path: parts[2]} + } + languages = append(languages, lang) + } + + return languages, nil +} + +// Plugins 获取主流的 mise 插件列表 (固定返回以确保速度和稳定性) +func (s *MiseService) Plugins() ([]string, error) { + mainstream := constant.MainstreamMisePlugins + logger.Infof("[Mise] Returning fixed list of %d mainstream plugins", len(mainstream)) + return mainstream, nil +} + +// Versions 获取指定插件的所有可用版本 +func (s *MiseService) Versions(plugin string) ([]string, error) { + if plugin == "" { + return []string{}, nil + } + + // 只获取最新版本列表 + cmd := exec.Command("mise", "ls-remote", plugin) + cmd.Env = append(cmd.Env, "MISE_NO_COLOR=1", "TERM=dumb") + output, err := cmd.CombinedOutput() + + if err != nil && len(output) == 0 { + logger.Errorf("[Mise] Fetch versions for %s failed: %v", plugin, err) + return []string{}, nil + } + + lines := strings.Split(string(output), "\n") + var versions []string + // 倒序排列,优先显示新版本 + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || strings.Contains(line, " ") || strings.Contains(line, "Usage:") || line == "latest" { + continue + } + versions = append(versions, line) + if len(versions) >= 300 { + break + } + } + + return versions, nil +} + +// enrichInstallDates 为语言列表添加安装日期信息 +func (s *MiseService) enrichInstallDates(languages []MiseLanguage) { + for i := range languages { + // 判断是否是 global + path := strings.ToLower(languages[i].Source.Path) + // 归一化路径分隔符 + normPath := strings.ReplaceAll(path, "\\", "/") + if languages[i].Source.Type == "global" { + languages[i].IsGlobal = true + } else if strings.Contains(normPath, "mise/config.toml") { + languages[i].IsGlobal = true + } + + if languages[i].InstallPath != "" { + if installDate := s.getInstallDate(languages[i].InstallPath); installDate != "" { + languages[i].InstalledAt = installDate + } + } + } +} + +// enrichSourceInfo 为语言列表添加来源信息 +func (s *MiseService) enrichSourceInfo(languages []MiseLanguage) { + for i := range languages { + // 如果source为空,使用install_path作为来源 + if languages[i].Source.Type == "" && languages[i].Source.Path == "" { + if languages[i].InstallPath != "" { + languages[i].Source.Path = languages[i].InstallPath + } + } + } +} + +// getInstallDate 获取安装路径的创建时间 +func (s *MiseService) getInstallDate(installPath string) string { + if installPath == "" { + return "" + } + + fileInfo, err := os.Stat(installPath) + if err != nil { + logger.Debugf("[Mise] Failed to stat install path %s: %v", installPath, err) + return "" + } + + // 获取修改时间作为安装时间的近似值 + modTime := fileInfo.ModTime() + return modTime.Format("2006-01-02 15:04:05") +} + +// sortByInstallDate 按安装时间降序排序(最新的在前面) +func (s *MiseService) sortByInstallDate(languages []MiseLanguage) { + sort.Slice(languages, func(i, j int) bool { + // 如果都有安装时间,按时间降序排序 + if languages[i].InstalledAt != "" && languages[j].InstalledAt != "" { + return languages[i].InstalledAt > languages[j].InstalledAt + } + // 有安装时间的排在前面 + if languages[i].InstalledAt != "" { + return true + } + if languages[j].InstalledAt != "" { + return false + } + // 都没有安装时间,按插件名排序 + return languages[i].Plugin < languages[j].Plugin + }) +} + +// syncToDB 将实时检测到的语言信息同步到数据库表中 +func (s *MiseService) syncToDB(languages []MiseLanguage) { + db := database.GetDB() + if db == nil { + return + } + + var currentIds []string + for _, lang := range languages { + var model models.Language + // 以 plugin 和 version 作为联合唯一标识(业务逻辑上) + res := db.Where("plugin = ? AND version = ?", lang.Plugin, lang.Version).Limit(1).Find(&model) + queryErr := res.Error + rowsAffected := res.RowsAffected + + sourceStr := "" + if lang.Source.Path != "" { + sourceStr = lang.Source.Path + } else if lang.Source.Type != "" { + sourceStr = lang.Source.Type + } + + var installTime *models.LocalTime + if lang.InstalledAt != "" { + t, err := time.Parse("2006-01-02 15:04:05", lang.InstalledAt) + if err == nil { + lt := models.LocalTime(t) + installTime = < + } + } + + if queryErr == nil && rowsAffected == 0 { + // 如果不存在,则创建 + newLang := models.Language{ + ID: utils.GenerateID(), + Plugin: lang.Plugin, + Version: lang.Version, + InstallPath: lang.InstallPath, + Source: sourceStr, + InstalledAt: installTime, + } + if err := db.Create(&newLang).Error; err == nil { + currentIds = append(currentIds, newLang.ID) + } + } else { + // 如果已存在,更新可能变动的信息 + updates := map[string]interface{}{ + "install_path": lang.InstallPath, + "source": sourceStr, + "installed_at": installTime, + } + db.Model(&model).Updates(updates) + currentIds = append(currentIds, model.ID) + } + } + + // 清理数据库中存在但实际 mise 已经卸载的记录 + if len(currentIds) > 0 { + db.Where("id NOT IN ?", currentIds).Delete(&models.Language{}) + } else { + // 如果本地一个都没有,清空表 + db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&models.Language{}) + } +} + +// GetVerifyCommand 获取环境验证命令 +func (s *MiseService) GetVerifyCommand(plugin, version string) (string, error) { + m := deps.GetManager(plugin) + if m == nil { + // 如果没找到对应的包管理器(比如 java 等不支持依赖管理的), + // 则提供一个基础的通用验证命令 + return utils.BuildMiseCommandSimple(plugin+" --version", plugin, version), nil + } + return m.GetVerifyCommand(version) +} + +// UseGlobal 设置全局默认版本 +func (s *MiseService) UseGlobal(plugin, version string) error { + cmd := exec.Command("mise", "use", "-g", fmt.Sprintf("%s@%s", plugin, version)) + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mise use -g failed: %v, output: %s", err, string(output)) + } + return nil +} + +// UnsetGlobal 取消全局默认版本 +func (s *MiseService) UnsetGlobal(plugin, version string) error { + // 使用 mise unuse -g @ 移出全局配置 + target := plugin + if version != "" { + target = fmt.Sprintf("%s@%s", plugin, version) + } + cmd := exec.Command("mise", "unuse", "-g", target) + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mise unuse global failed: %v, output: %s", err, string(output)) + } + return nil +} + +// Envs 获取全局环境变量 +func (s *MiseService) Envs() (map[string]string, error) { + cmd := exec.Command("mise", "set") + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("mise set failed: %v, output: %s", err, string(output)) + } + + envs := make(map[string]string) + lines := strings.Split(string(output), "\n") + for _, line := range lines { + fields := strings.Fields(line) + // 期望格式: KEY VALUE SOURCE + // 跳过标题行或不合规行 + if len(fields) < 3 || strings.ToLower(fields[0]) == "key" { + continue + } + // 仅显示来自全局配置文件的环境变量 + if strings.Contains(strings.ToLower(fields[len(fields)-1]), "config.toml") { + envs[fields[0]] = fields[1] + } + } + return envs, nil +} + +// SetEnv 设置全局环境变量 +func (s *MiseService) SetEnv(key, value string) error { + cmd := exec.Command("mise", "set", "-g", fmt.Sprintf("%s=%s", key, value)) + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mise set -g failed: %v, output: %s", err, string(output)) + } + return nil +} + +// UnsetEnv 取消全局环境变量 +func (s *MiseService) UnsetEnv(key string) error { + cmd := exec.Command("mise", "unset", "-g", key) + cmd.Env = os.Environ() + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("mise unset -g failed: %v, output: %s", err, string(output)) + } + return nil +} diff --git a/internal/services/monitor_service.go b/internal/services/monitor_service.go new file mode 100644 index 0000000..42979fe --- /dev/null +++ b/internal/services/monitor_service.go @@ -0,0 +1,132 @@ +package services + +import ( + "math/rand" + "runtime" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" +) + +type HostMetrics struct { + CPUPercent float64 + VMem *mem.VirtualMemoryStat + DiskUsage *disk.UsageStat + HostInfo *host.InfoStat +} + +type MonitorService struct { + hostMu sync.RWMutex + lastUpdate time.Time + metrics HostMetrics +} + +var ( + monitorServiceInstance *MonitorService + monitorServiceOnce sync.Once +) + +// GetMonitorService 获取系统监控服务单例 +func GetMonitorService() *MonitorService { + monitorServiceOnce.Do(func() { + monitorServiceInstance = &MonitorService{} + }) + return monitorServiceInstance +} + +// GetHostMetrics 获取并返回物理机状态(带有缓存和演示模式伪装) +func (ms *MonitorService) GetHostMetrics() HostMetrics { + ms.hostMu.Lock() + defer ms.hostMu.Unlock() + + // 缓存 2 秒 + if time.Since(ms.lastUpdate) < 2*time.Second && ms.metrics.VMem != nil { + return ms.metrics + } + + if constant.DemoMode { + ms.updateDemoMetrics() + return ms.metrics + } + + cpuPercents, _ := cpu.Percent(0, false) + if len(cpuPercents) > 0 { + ms.metrics.CPUPercent = cpuPercents[0] + } + ms.metrics.VMem, _ = mem.VirtualMemory() + ms.metrics.DiskUsage, _ = disk.Usage("/") + ms.metrics.HostInfo, _ = host.Info() + ms.lastUpdate = time.Now() + + // 提供默认值防空指针 + if ms.metrics.VMem == nil { + ms.metrics.VMem = &mem.VirtualMemoryStat{} + } + if ms.metrics.DiskUsage == nil { + ms.metrics.DiskUsage = &disk.UsageStat{} + } + if ms.metrics.HostInfo == nil { + ms.metrics.HostInfo = &host.InfoStat{} + } + + return ms.metrics +} + +type RuntimeMetrics struct { + NumGoroutine int + MemStats runtime.MemStats +} + +var ( + runtimeMu sync.RWMutex + lastRuntime time.Time + cachedRuntime RuntimeMetrics +) + +// GetRuntimeMetrics 获取 Go 运行时指标(缓存 2 秒,防止高并发下频繁触发 STW) +func (ms *MonitorService) GetRuntimeMetrics() RuntimeMetrics { + runtimeMu.Lock() + defer runtimeMu.Unlock() + + if time.Since(lastRuntime) < 2*time.Second && cachedRuntime.NumGoroutine > 0 { + return cachedRuntime + } + + cachedRuntime.NumGoroutine = runtime.NumGoroutine() + runtime.ReadMemStats(&cachedRuntime.MemStats) + lastRuntime = time.Now() + + return cachedRuntime +} + +func (ms *MonitorService) updateDemoMetrics() { + ms.metrics.CPUPercent = 10 + rand.Float64()*40 // 10% - 50% 的随机 CPU 波动 + + totalMem := uint64(8 * 1024 * 1024 * 1024) // 8GB 内存 + usedMem := uint64(float64(totalMem) * (0.3 + rand.Float64()*0.3)) // 30% - 60% 随机使用率 + ms.metrics.VMem = &mem.VirtualMemoryStat{ + Total: totalMem, + Used: usedMem, + UsedPercent: float64(usedMem) / float64(totalMem) * 100, + } + + totalDisk := uint64(500 * 1024 * 1024 * 1024) // 500GB 硬盘 + usedDisk := uint64(float64(totalDisk) * 0.45) // 固定 45% 使用率 + ms.metrics.DiskUsage = &disk.UsageStat{ + Total: totalDisk, + Used: usedDisk, + UsedPercent: float64(usedDisk) / float64(totalDisk) * 100, + } + + ms.metrics.HostInfo = &host.InfoStat{ + Platform: "Demo Environment", + OS: "linux", + Uptime: uint64(time.Now().Unix() - 1700000000), // 生成一个较长且持续增加的运行时间 + } + ms.lastUpdate = time.Now() +} diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go new file mode 100644 index 0000000..2db26d2 --- /dev/null +++ b/internal/services/notification_service.go @@ -0,0 +1,564 @@ +package services + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" + + "github.com/engigu/taskpool/internal/sdk/messenger" + "gorm.io/gorm" + "regexp" + "strings" +) + +// NotifyChannel 通知渠道配置 +type NotifyChannel struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + CreatedAt models.LocalTime `json:"created_at"` + Config map[string]string `json:"config"` +} + +// NotifyMessage 通知消息 +type NotifyMessage struct { + Title string `json:"title"` + Text string `json:"text"` +} + +// NotifyResult 发送结果 +type NotifyResult struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// SupportedChannelTypes 支持的渠道类型 +var SupportedChannelTypes = []map[string]string{ + {"type": messenger.ChannelTelegram, "label": "Telegram"}, + {"type": messenger.ChannelBark, "label": "Bark"}, + {"type": messenger.ChannelDtalk, "label": "钉钉"}, + {"type": messenger.ChannelQyWeiXin, "label": "企业微信"}, + {"type": messenger.ChannelFeishu, "label": "飞书"}, + {"type": messenger.ChannelEmail, "label": "邮件"}, + {"type": messenger.ChannelCustom, "label": "自定义Webhook"}, + {"type": messenger.ChannelNtfy, "label": "Ntfy"}, + {"type": messenger.ChannelGotify, "label": "Gotify"}, + {"type": messenger.ChannelPushMe, "label": "PushMe"}, + // {"type": messenger.ChannelWeChatOFAccount, "label": "微信公众号"}, + {"type": messenger.ChannelAliyunSMS, "label": "阿里云短信"}, + {"type": messenger.ChannelPushPlus, "label": "PushPlus"}, + {"type": messenger.ChannelVoceChat, "label": "VoceChat"}, + {"type": messenger.ChannelWxPusher, "label": "WxPusher"}, +} + +// SupportedEvents 支持的事件类型 +var SupportedEvents = []map[string]string{ + {"type": constant.EventUserLogin, "label": "用户登录", "binding_type": constant.BindingTypeSystem}, + {"type": constant.EventBruteForceLogin, "label": "密码多次错误", "binding_type": constant.BindingTypeSystem}, + {"type": constant.EventPasswordChanged, "label": "密码修改", "binding_type": constant.BindingTypeSystem}, + {"type": constant.EventTaskSuccess, "label": "任务成功", "binding_type": constant.BindingTypeTask}, + {"type": constant.EventTaskFailed, "label": "任务失败", "binding_type": constant.BindingTypeTask}, + {"type": constant.EventTaskTimeout, "label": "任务超时", "binding_type": constant.BindingTypeTask}, +} + +type NotificationService struct { + settingsService *SettingsService + mu sync.RWMutex +} + +func NewNotificationService() *NotificationService { + return &NotificationService{ + settingsService: NewSettingsService(), + } +} + +// GetChannels 获取所有渠道 +func (s *NotificationService) GetChannels() []NotifyChannel { + s.mu.RLock() + defer s.mu.RUnlock() + return s.getChannelsInternal() +} + +// SaveChannel 保存/更新渠道 +func (s *NotificationService) SaveChannel(channel NotifyChannel) error { + s.mu.Lock() + defer s.mu.Unlock() + + configJSON, err := json.Marshal(channel.Config) + if err != nil { + return err + } + + if channel.ID == "" { + // 新建 + channel.ID = utils.GenerateID() + notifyWay := &models.NotifyWay{ + ID: channel.ID, + Name: channel.Name, + Type: channel.Type, + Config: models.BigText(configJSON), + Enabled: utils.BoolPtr(channel.Enabled), + } + return database.DB.Create(notifyWay).Error + } + + // 更新 + updates := map[string]interface{}{ + "name": channel.Name, + "type": channel.Type, + "config": models.BigText(configJSON), + "enabled": &channel.Enabled, + } + return database.DB.Model(&models.NotifyWay{}).Where("id = ?", channel.ID).Updates(updates).Error +} + +// DeleteChannel 删除渠道 +func (s *NotificationService) DeleteChannel(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // 检查渠道是否存在 + var count int64 + database.DB.Model(&models.NotifyWay{}).Where("id = ?", id).Count(&count) + if count == 0 { + return fmt.Errorf("渠道 %s 不存在", id) + } + + // 删除渠道 + if err := database.DB.Where("id = ?", id).Delete(&models.NotifyWay{}).Error; err != nil { + return err + } + + // 同时清理事件绑定中引用此渠道的配置 + if err := database.DB.Where("way_id = ?", id).Delete(&models.NotifyBinding{}).Error; err != nil { + logger.Errorf("[Notify] 清理事件绑定失败: %v", err) + } + + return nil +} + +// GetBindings 获取事件绑定列表(新接口,用于前端展示) +func (s *NotificationService) GetBindings() []models.NotifyBinding { + var bindings []models.NotifyBinding + database.DB.Find(&bindings) + return bindings +} + +// SaveBinding 保存事件绑定 +func (s *NotificationService) SaveBinding(binding *models.NotifyBinding) error { + if binding.ID == "" { + // 检查是否已经存在相同的绑定(避免重复点击导致多个记录) + var existing models.NotifyBinding + res := database.DB.Where("type = ? AND event = ? AND way_id = ? AND data_id = ?", + binding.Type, binding.Event, binding.WayID, binding.DataID).Limit(1).Find(&existing) + if res.Error == nil && res.RowsAffected > 0 { + // 如果已存在且未删除,更新现有记录(特别是 Extra 字段) + existing.Extra = binding.Extra + err := database.DB.Save(&existing).Error + if err == nil { + *binding = existing + } + return err + } + + binding.ID = utils.GenerateID() + return database.DB.Create(binding).Error + } + return database.DB.Save(binding).Error +} + +// BatchSaveBindings 批量保存事件绑定 +func (s *NotificationService) BatchSaveBindings(bindingType, dataID string, bindings []models.NotifyBinding) error { + return database.DB.Transaction(func(tx *gorm.DB) error { + // 如果指定了 dataID,先清理该对象的所有现有绑定 + if dataID != "" { + if err := tx.Where("type = ? AND data_id = ?", bindingType, dataID).Delete(&models.NotifyBinding{}).Error; err != nil { + return err + } + } + + // 批量插入新绑定 + for i := range bindings { + bindings[i].ID = utils.GenerateID() + bindings[i].Type = bindingType + bindings[i].DataID = dataID + if err := tx.Create(&bindings[i]).Error; err != nil { + return err + } + } + return nil + }) +} + +// DeleteBinding 删除事件绑定 +func (s *NotificationService) DeleteBinding(id string) error { + return database.DB.Where("id = ?", id).Delete(&models.NotifyBinding{}).Error +} + +// GetBindingsByEvent 根据事件类型和数据ID获取绑定 +func (s *NotificationService) GetBindingsByEvent(bindingType, event, dataID string) []models.NotifyBinding { + var bindings []models.NotifyBinding + + // 如果是任务事件且带有 dataID,只获取特定任务的绑定(禁用全局任务配置) + if bindingType == constant.BindingTypeTask && dataID != "" { + database.DB.Where("type = ? AND event = ? AND data_id = ?", constant.BindingTypeTask, event, dataID).Find(&bindings) + return bindings + } + + // 对于系统事件或其他情况 + query := database.DB.Where("event = ?", event) + if bindingType != "" { + query = query.Where("type = ?", bindingType) + } + + if dataID != "" { + query = query.Where("data_id = ?", dataID) + } else { + query = query.Where("data_id = ? OR data_id IS NULL", "") + } + + query.Find(&bindings) + return bindings +} + +// SendToChannel 使用 messenger SDK 发送通知到指定渠道 +func (s *NotificationService) SendToChannel(channel NotifyChannel, msg *NotifyMessage) *NotifyResult { + result, err := messenger.Send(channel.Type, messenger.ChannelConfig(channel.Config), &messenger.Message{ + Title: msg.Title, + Text: msg.Text, + }) + + payload := map[string]interface{}{ + "title": msg.Title, + "content": msg.Text, + "channel_id": channel.ID, + "channel_name": channel.Name, + "success": false, + "error_msg": "", + } + + if err != nil { + payload["error_msg"] = err.Error() + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventNotifySent, + Payload: payload, + }) + return &NotifyResult{Success: false, Error: err.Error()} + } + + if !result.Success { + payload["error_msg"] = result.Error + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventNotifySent, + Payload: payload, + }) + return &NotifyResult{Success: false, Error: result.Error} + } + + payload["success"] = true + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventNotifySent, + Payload: payload, + }) + + return &NotifyResult{Success: true} +} + +// SendByChannelID 根据渠道ID发送通知 +func (s *NotificationService) SendByChannelID(channelID string, msg *NotifyMessage) *NotifyResult { + s.mu.RLock() + defer s.mu.RUnlock() + + var notifyWay models.NotifyWay + res := database.DB.Where("id = ?", channelID).Limit(1).Find(¬ifyWay) + if res.Error != nil || res.RowsAffected == 0 { + return &NotifyResult{Success: false, Error: "渠道不存在"} + } + + if !utils.DerefBool(notifyWay.Enabled, true) { + return &NotifyResult{Success: false, Error: "渠道已禁用"} + } + + var config map[string]string + if err := json.Unmarshal([]byte(notifyWay.Config), &config); err != nil { + return &NotifyResult{Success: false, Error: "渠道配置解析失败"} + } + + ch := NotifyChannel{ + ID: notifyWay.ID, + Name: notifyWay.Name, + Type: notifyWay.Type, + Enabled: utils.DerefBool(notifyWay.Enabled, true), + Config: config, + } + return s.SendToChannel(ch, msg) +} + +// SubscribeEvents 注册通知服务自身为事件流的订阅者 +func (s *NotificationService) SubscribeEvents(bus *eventbus.EventBus) { + // 系统事件 + systemEvents := []string{constant.EventUserLogin, constant.EventBruteForceLogin, constant.EventPasswordChanged} + for _, evt := range systemEvents { + bus.Subscribe(evt, s.handleEvent(constant.BindingTypeSystem)) + } + + // 任务事件 + taskEvents := []string{constant.EventTaskSuccess, constant.EventTaskFailed, constant.EventTaskTimeout} + for _, evt := range taskEvents { + bus.Subscribe(evt, s.handleEvent(constant.BindingTypeTask)) + } + + // 通用系统通知 + bus.Subscribe(constant.EventSystemNotice, s.handleEvent(constant.BindingTypeSystem)) +} + +var ansiRegexp = regexp.MustCompile(`[\x1b\x9b][\[()#;?]*([0-9]{1,4}(;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`) + +// stripAnsi 移除字符串中的 ANSI 转义码(如颜色代码) +func stripAnsi(str string) string { + return ansiRegexp.ReplaceAllString(str, "") +} + +// parseTemplate 简单的 {{key}} 模板替换 +func (s *NotificationService) parseTemplate(tmpl string, payload map[string]interface{}) string { + result := tmpl + for k, v := range payload { + placeholder := fmt.Sprintf("{{%s}}", k) + valStr := fmt.Sprintf("%v", v) + result = strings.ReplaceAll(result, placeholder, valStr) + } + return result +} + +// getDefaultMessage 兜底默认消息内容 +func (s *NotificationService) getDefaultMessage(eventType string, payload map[string]interface{}) (string, string) { + var title, text string + switch eventType { + case constant.EventUserLogin: + status, _ := payload["status"].(string) + if status == "success" { + title = "用户登录成功" + text = fmt.Sprintf("用户 %v 在 IP %v 登录成功", payload["username"], payload["ip"]) + } else { + title = "用户登录失败" + reason, _ := payload["message"].(string) + text = fmt.Sprintf("用户 %v 在 IP %v 登录失败\n原因: %v", payload["username"], payload["ip"], reason) + } + case constant.EventBruteForceLogin: + title = "系统安全警告" + text = fmt.Sprintf("检测到 IP %v 正在尝试暴力破解用户 %v", payload["ip"], payload["username"]) + case constant.EventPasswordChanged: + title = "账户安全通知" + text = fmt.Sprintf("用户 %v 刚刚修改了密码", payload["username"]) + case constant.EventTaskSuccess: + title = fmt.Sprintf("任务[%v] 成功", payload["task_name"]) + text = fmt.Sprintf("任务 #%v %v\n状态: 成功\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"]) + case constant.EventTaskFailed: + title = fmt.Sprintf("任务[%v] 失败", payload["task_name"]) + if errStr, ok := payload["error"]; ok { + text = fmt.Sprintf("任务 #%v %v\n执行失败\n执行时间: %v\n错误: %v", payload["task_id"], payload["task_name"], payload["start_time"], errStr) + } else { + text = fmt.Sprintf("任务 #%v %v\n执行失败\n状态: %v\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["status"], payload["start_time"], payload["duration"]) + } + case constant.EventTaskTimeout: + title = fmt.Sprintf("任务[%v] 超时", payload["task_name"]) + text = fmt.Sprintf("任务 #%v %v\n执行超时\n执行时间: %v\n耗时: %vms", payload["task_id"], payload["task_name"], payload["start_time"], payload["duration"]) + } + return title, text +} + +// resolveEvent 解析不同事件类型,返回对应的模板Key、静态内容(非模板事件)和原始任务输出 +func (s *NotificationService) resolveEvent(eventType string, payload map[string]interface{}) (tmplTitleKey, tmplTextKey, title, text, rawOutput string, ok bool) { + switch eventType { + case constant.EventUserLogin: + tmplTitleKey = constant.KeyNotifyTemplateUserLoginTitle + tmplTextKey = constant.KeyNotifyTemplateUserLoginText + // 特殊处理登录状态 + status, _ := payload["status"].(string) + if status == "success" { + payload["status_label"] = "成功" + } else { + payload["status_label"] = "失败" + } + + case constant.EventBruteForceLogin: + tmplTitleKey = constant.KeyNotifyTemplateBruteForceLoginTitle + tmplTextKey = constant.KeyNotifyTemplateBruteForceLoginText + + case constant.EventPasswordChanged: + tmplTitleKey = constant.KeyNotifyTemplatePasswordChangedTitle + tmplTextKey = constant.KeyNotifyTemplatePasswordChangedText + + case constant.EventTaskSuccess, constant.EventTaskFailed, constant.EventTaskTimeout: + switch eventType { + case constant.EventTaskSuccess: + tmplTitleKey = constant.KeyNotifyTemplateTaskSuccessTitle + tmplTextKey = constant.KeyNotifyTemplateTaskSuccessText + case constant.EventTaskFailed: + tmplTitleKey = constant.KeyNotifyTemplateTaskFailedTitle + tmplTextKey = constant.KeyNotifyTemplateTaskFailedText + case constant.EventTaskTimeout: + tmplTitleKey = constant.KeyNotifyTemplateTaskTimeoutTitle + tmplTextKey = constant.KeyNotifyTemplateTaskTimeoutText + } + + // 处理输出内容,避免过长 + if output, ok := payload["output"].(string); ok { + rawOutput = output + // trimmed := utils.TrimLastRunes(output, 1000) + // if len(trimmed) < len(output) { + // payload["output"] = trimmed + "\n...(截断)" + // } + } + + case constant.EventSystemNotice: + title, _ = payload["title"].(string) + text, _ = payload["content"].(string) + default: + return "", "", "", "", "", false + } + return tmplTitleKey, tmplTextKey, title, text, rawOutput, true +} + +// buildMessage 匹配并解析模板内容,提供兜底消息并拼接全局前缀 +func (s *NotificationService) buildMessage(eventType string, tmplTitleKey, tmplTextKey, defaultTitle, defaultText, prefix string, payload map[string]interface{}) (title, text string) { + title = defaultTitle + text = defaultText + + if tmplTitleKey != "" { + tmplTitle := s.settingsService.Get(constant.SectionNotify, tmplTitleKey) + tmplText := s.settingsService.Get(constant.SectionNotify, tmplTextKey) + + if tmplTitle != "" { + title = s.parseTemplate(tmplTitle, payload) + } + if tmplText != "" { + text = s.parseTemplate(tmplText, payload) + } + + // 如果模板为空,使用兜底默认逻辑(保持向上兼容) + if title == "" || text == "" { + title, text = s.getDefaultMessage(eventType, payload) + } + } + + // 添加全局前缀 + if prefix != "" && title != "" { + title = fmt.Sprintf("%s %s", prefix, title) + } + return title, text +} + +// handleEvent 处理事件订阅并发送通知 +func (s *NotificationService) handleEvent(bindingType string) eventbus.Handler { + return func(e eventbus.Event) { + payload, ok := e.Payload.(map[string]interface{}) + if !ok { + return + } + + var dataID string + if id, ok := payload["task_id"].(string); ok { + dataID = id + } + + // 获取全局前缀并解析事件数据 + prefix := s.settingsService.Get(constant.SectionNotify, constant.KeyNotifyPrefix) + tmplTitleKey, tmplTextKey, title, text, rawOutput, ok := s.resolveEvent(e.Type, payload) + if !ok { + return + } + + // 构建最终的通知标题和正文文本 + title, text = s.buildMessage(e.Type, tmplTitleKey, tmplTextKey, title, text, prefix, payload) + + bindings := s.GetBindingsByEvent(bindingType, e.Type, dataID) + if len(bindings) == 0 { + return + } + + var cleanLog string + if rawOutput != "" { + // cleanLog = stripAnsi(rawOutput) + cleanLog = rawOutput + } + + channels := s.GetChannels() + channelMap := make(map[string]NotifyChannel) + for _, ch := range channels { + channelMap[ch.ID] = ch + } + + for _, binding := range bindings { + ch, ok := channelMap[binding.WayID] + if !ok || !ch.Enabled { + continue + } + + // 克隆文本以便修改 + currentText := text + + // 解析额外配置 + var extra models.BindingExtra + if binding.Extra != "" { + _ = json.Unmarshal([]byte(binding.Extra), &extra) + } + // 默认日志限制为 1000 + if extra.LogLimit <= 0 { + extra.LogLimit = 1000 + } + + // 如果开启了日志推送 + if extra.EnableLog { + if cleanLog != "" { + trimmed := utils.TrimLastRunes(cleanLog, extra.LogLimit) + if len(trimmed) < len(cleanLog) { + trimmed = "...\n" + trimmed + } + currentText += "\n\n[执行日志]\n" + trimmed + } + } + + go func(channel NotifyChannel, msgTitle, msgText string) { + result := s.SendToChannel(channel, &NotifyMessage{Title: msgTitle, Text: msgText}) + if !result.Success { + logger.Warnf("[Notify] 发送事件 %s 到渠道 %s(%s) 失败: %s", e.Type, channel.Name, channel.Type, result.Error) + } + }(ch, title, currentText) + } + } +} + +// --- 内部方法 --- + +// getChannelsInternal 从 notify_ways 表中读取所有渠道配置 +func (s *NotificationService) getChannelsInternal() []NotifyChannel { + var notifyWays []models.NotifyWay + database.DB.Find(¬ifyWays) + + channels := make([]NotifyChannel, 0, len(notifyWays)) + for _, nw := range notifyWays { + var config map[string]string + if err := json.Unmarshal([]byte(nw.Config), &config); err != nil { + logger.Warnf("[Notify] 解析渠道 %s 配置失败: %v", nw.ID, err) + continue + } + channels = append(channels, NotifyChannel{ + ID: nw.ID, + Name: nw.Name, + Type: nw.Type, + Enabled: utils.DerefBool(nw.Enabled, true), + CreatedAt: nw.CreatedAt, + Config: config, + }) + } + return channels +} diff --git a/internal/services/relation/relation_service.go b/internal/services/relation/relation_service.go new file mode 100644 index 0000000..043034b --- /dev/null +++ b/internal/services/relation/relation_service.go @@ -0,0 +1,141 @@ +package relation + +import ( + "strings" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/rs/xid" +) + +type DataRelationService struct{} + +var DataRelation = &DataRelationService{} + +// SaveTags 保存带有 Storage (文本标签) 的关系映射 +func (s *DataRelationService) SaveTags(dataID string, relType string, tagsStr string) { + database.DB.Where("data_id = ? AND type = ?", dataID, relType).Delete(&models.DataRelation{}) + if tagsStr == "" { + return + } + tags := strings.Split(tagsStr, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + var storage models.DataStorage + res := database.DB.Where("type = ? AND name = ?", relType, tag).Limit(1).Find(&storage) + if res.RowsAffected == 0 { + storage = models.DataStorage{ + ID: xid.New().String(), + Type: relType, + Name: tag, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + database.DB.Create(&storage) + } + relation := models.DataRelation{ + ID: xid.New().String(), + DataID: dataID, + RelateID: storage.ID, + Type: relType, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + database.DB.Create(&relation) + } +} + +// LoadTags 加载带有 Storage (文本标签) 的映射,返回 map[DataID][]TagName +func (s *DataRelationService) LoadTags(dataIDs []string, relType string) map[string][]string { + if len(dataIDs) == 0 { + return nil + } + var relations []models.DataRelation + database.DB.Where("data_id IN ? AND type = ?", dataIDs, relType).Find(&relations) + + if len(relations) == 0 { + return nil + } + + var relateIDs []string + for _, r := range relations { + relateIDs = append(relateIDs, r.RelateID) + } + + var storages []models.DataStorage + database.DB.Where("id IN ?", relateIDs).Find(&storages) + + storageMap := make(map[string]string) + for _, storage := range storages { + storageMap[storage.ID] = storage.Name + } + + resultMap := make(map[string][]string) + for _, r := range relations { + if name, ok := storageMap[r.RelateID]; ok { + resultMap[r.DataID] = append(resultMap[r.DataID], name) + } + } + return resultMap +} + +// SaveRelations 保存单纯的关系映射 (例如 ID关联) +func (s *DataRelationService) SaveRelations(dataID string, relType string, relateIDsStr string) { + database.DB.Where("data_id = ? AND type = ?", dataID, relType).Delete(&models.DataRelation{}) + if relateIDsStr == "" { + return + } + ids := strings.Split(relateIDsStr, ",") + for _, relateID := range ids { + relateID = strings.TrimSpace(relateID) + if relateID == "" { + continue + } + relation := models.DataRelation{ + ID: xid.New().String(), + DataID: dataID, + RelateID: relateID, + Type: relType, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + database.DB.Create(&relation) + } +} + +// LoadRelations 加载单纯的关系映射,返回 map[DataID][]RelateID +func (s *DataRelationService) LoadRelations(dataIDs []string, relType string) map[string][]string { + if len(dataIDs) == 0 { + return nil + } + var relations []models.DataRelation + database.DB.Where("data_id IN ? AND type = ?", dataIDs, relType).Find(&relations) + + resultMap := make(map[string][]string) + for _, r := range relations { + resultMap[r.DataID] = append(resultMap[r.DataID], r.RelateID) + } + return resultMap +} + +// CleanRelations 删除某种类型的所有关联映射 +func (s *DataRelationService) CleanRelations(dataID string, relType string) { + database.DB.Where("data_id = ? AND type = ?", dataID, relType).Delete(&models.DataRelation{}) +} + +// GetAllTags 获取全局范围内某种类型的所有的 Tag Name +func (s *DataRelationService) GetAllTags(relType string) ([]string, error) { + var storages []models.DataStorage + err := database.DB.Where("type = ?", relType).Find(&storages).Error + if err != nil { + return nil, err + } + var tags []string + for _, s := range storages { + tags = append(tags, s.Name) + } + return tags, nil +} diff --git a/internal/services/repo/repo_parser.go b/internal/services/repo/repo_parser.go new file mode 100644 index 0000000..ed178d6 --- /dev/null +++ b/internal/services/repo/repo_parser.go @@ -0,0 +1,242 @@ +package repo + +import ( + "encoding/json" + "fmt" + "io" + "io/fs" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/relation" + "github.com/engigu/taskpool/internal/utils" +) + +// ParseRepoScriptsAndAddCron 扫描仓库目录中的脚本,解析 cron 和环境注释,并注册任务 +func ParseRepoScriptsAndAddCron(taskID string, logWriter io.Writer, forceCommentToTask bool) ([]string, []string) { + // 帮助函数:如果提供了 logWriter,则将日志输出到该处 + log := func(format string, a ...interface{}) { + msg := fmt.Sprintf(format, a...) + if !strings.HasSuffix(msg, "\n") { + msg += "\n" + } + if logWriter != nil { + logWriter.Write([]byte(msg)) + } + } + + var repoTask models.Task + res := database.DB.Where("id = ?", taskID).Limit(1).Find(&repoTask) + if res.Error != nil || res.RowsAffected == 0 { + return nil, nil + } + + if repoTask.Type != constant.TaskTypeRepo { + return nil, nil + } + + var repoCfg models.RepoConfig + if err := json.Unmarshal([]byte(repoTask.Config), &repoCfg); err != nil { + return nil, nil + } + + // 如果命令行强制开启,则覆盖配置 + if forceCommentToTask { + repoCfg.CommentToTask = "true" + } + + // 1. 确定解析策略 + strategy := GetParserStrategy(repoCfg.RepoSource) + + // 目标路径 + targetPath := repoCfg.TargetPath + if targetPath == "" { + targetPath = repoTask.WorkDir + } else if !filepath.IsAbs(targetPath) { + targetPath = filepath.Join(utils.ResolveAbsScriptsDir(), targetPath) + } + if targetPath == "" { + return nil, nil + } + targetPath = filepath.Clean(targetPath) + + // 获取仓库标识符 + repoId := utils.GetRepoIdentifier(repoCfg.SourceURL, repoCfg.Branch) + + gitDir := filepath.Join(targetPath, ".git") + if !isDir(targetPath) || !pathExists(gitDir) { + repoPath := filepath.Join(targetPath, repoId) + if pathExists(repoPath) { + targetPath = repoPath + } + } + + if !pathExists(targetPath) { + return nil, nil + } + + // 同步过程中使用的标签 + tag := fmt.Sprintf("%s", repoId) + + exts := getValidExtensions(repoCfg.Extensions) + + log("\n----------------------------------------") + log(" 开始扫描脚本并自动注册定时任务 ") + log("----------------------------------------") + + foundSourceIDs := make(map[string]bool) + var upsertedIDs []string + var deletedIDs []string + newTaskCount := 0 + updateTaskCount := 0 + + filepath.WalkDir(targetPath, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + + if strings.Contains(path, ".git") { + return nil + } + + ext := filepath.Ext(path) + if !strategy.SupportExtension(ext, exts) { + return nil + } + + // 2. 元数据提取 + taskName, taskCron := strategy.ExtractMeta(path, ext, repoCfg) + + // 3. 过滤处理 + relRepoPath, _ := filepath.Rel(targetPath, path) + filename := filepath.Base(path) + if !strategy.ShouldProcess(relRepoPath, filename, repoCfg) { + return nil + } + + if taskName != "" && taskCron != "" && repoCfg.AutoAddCron { + // 获取脚本相对于数据目录的路径 + absScriptsDir := utils.ResolveAbsScriptsDir() + // absTargetPath, _ := filepath.Abs(targetPath) + absPath, _ := filepath.Abs(path) + + // 计算 SourceID: 相对于脚本目录的完整路径,并清洗特殊符号 + relPath, _ := filepath.Rel(absScriptsDir, absPath) + sourceID := sanitizeIdentifier(relPath) + + // 替换绝对路径为代号 $SCRIPTS_DIR$ + displayPath := path + displayWorkDir := targetPath + if strings.HasPrefix(absPath, absScriptsDir) { + // 命令仅使用文件名,工作目录设置为脚本所在目录 + displayPath = filepath.Base(path) + relDir, _ := filepath.Rel(absScriptsDir, filepath.Dir(absPath)) + if relDir == "." { + displayWorkDir = constant.ScriptsDirPlaceholder + } else { + displayWorkDir = filepath.ToSlash(filepath.Join(constant.ScriptsDirPlaceholder, relDir)) + } + } + + // 找到任务,进行保存 + command := getCommandByExt(ext, displayPath) + taskID, isNew := upsertRepoTask(&repoTask, sourceID, taskName, command, taskCron, displayWorkDir, tag) + + if isNew { + log("[新增] 任务: %s (%s)", taskName, filename) + newTaskCount++ + } else { + log("[更新] 任务: %s (%s)", taskName, filename) + updateTaskCount++ + } + foundSourceIDs[sourceID] = true + upsertedIDs = append(upsertedIDs, taskID) + } + + return nil + }) + + // 清理该仓库下不再存在的旧脚本任务 + deletedTaskCount := 0 + var oldTasks []models.Task + if err := database.DB.Where("repo_task_id = ?", repoTask.ID).Find(&oldTasks).Error; err == nil { + for _, ot := range oldTasks { + if !foundSourceIDs[ot.SourceID] { + log("[移除] 脚本已不存在,删除对应任务: %s", ot.Name) + deletedTaskCount++ + deletedIDs = append(deletedIDs, ot.ID) + + // 删除关联 + relation.DataRelation.CleanRelations(ot.ID, constant.RelationTypeTaskTag) + relation.DataRelation.CleanRelations(ot.ID, constant.RelationTypeTaskEnv) + + database.DB.Unscoped().Where("id = ?", ot.ID).Delete(&models.Task{}) + } + } + } + + log("\n扫描完成: [新增 %d] [更新 %d] [移除 %d]", newTaskCount, updateTaskCount, deletedTaskCount) + log("----------------------------------------") + return upsertedIDs, deletedIDs +} + +// upsertRepoTask 处理来自仓库的任务的创建或更新 +func upsertRepoTask(parentTask *models.Task, sourceID, name, command, cron, workDir, tag string) (string, bool) { + defaultTaskConfig := `{"$task_all_envs":true,"$task_concurrency":0}` + var existing models.Task + tx := database.DB.Where("source_id = ? AND repo_task_id = ?", sourceID, parentTask.ID).Limit(1).Find(&existing) + + if tx.RowsAffected > 0 { + // 更新操作 + existing.Name = name + existing.Command = models.BigText(command) + existing.Schedule = normalizeCron(cron) + existing.Languages = parentTask.Languages + existing.SourceID = sourceID + existing.RepoTaskID = parentTask.ID + existing.WorkDir = workDir + // 如果原配置为空或者是 {},则应用默认配置 + if string(existing.Config) == "" || string(existing.Config) == "{}" { + existing.Config = models.BigText(defaultTaskConfig) + } + // 默认开启按条数清理30条 + if existing.CleanConfig == "" { + existing.CleanConfig = `{"type":"count","keep":30}` + } + // 显式白名单模式:只更新脚本核心相关的字段,其他所有字段(如 Enabled, Pin, Remark 等)均不触碰 + database.DB.Model(&existing). + Select("Name", "Command", "Schedule", "WorkDir", "Languages"). + Updates(&existing) + return existing.ID, false + } else { + // 创建新任务 + newTask := &models.Task{ + Name: name, + Command: models.BigText(command), + Schedule: normalizeCron(cron), + Type: "task", + TriggerType: constant.TriggerTypeCron, + Tags: tag, + Languages: parentTask.Languages, + Timeout: parentTask.Timeout, + Config: models.BigText(defaultTaskConfig), + Enabled: utils.BoolPtr(true), + WorkDir: workDir, + SourceID: sourceID, + RepoTaskID: parentTask.ID, + CleanConfig: `{"type":"count","keep":30}`, + } + newTask.ID = utils.GenerateID() + database.DB.Create(newTask) + + // 确保将标签同步写入到新的 DataRelation 中 + if tag != "" { + relation.DataRelation.SaveTags(newTask.ID, constant.RelationTypeTaskTag, tag) + } + + return newTask.ID, true + } +} diff --git a/internal/services/repo/strategy.go b/internal/services/repo/strategy.go new file mode 100644 index 0000000..33128c0 --- /dev/null +++ b/internal/services/repo/strategy.go @@ -0,0 +1,27 @@ +package repo + +import ( + "github.com/engigu/taskpool/internal/models" +) + +// RepoParserStrategy 定义不同仓库解析策略的接口 +type RepoParserStrategy interface { + // SupportExtension 判断给定后缀的文件是否应该被处理 + SupportExtension(ext string, exts []string) bool + + // ShouldProcess 应用白名单/黑名单过滤,决定是否处理该文件 + ShouldProcess(relRepoPath, filename string, cfg models.RepoConfig) bool + + // ExtractMeta 从脚本文件中提取任务元数据(名称和 cron 表达式) + ExtractMeta(path string, ext string, cfg models.RepoConfig) (taskName string, taskCron string) +} + +// GetParserStrategy 根据来源类型返回相应的策略实现 +func GetParserStrategy(sourceType string) RepoParserStrategy { + switch sourceType { + case "ql": + return &QinglongStrategy{} + default: + return &StandardStrategy{} + } +} diff --git a/internal/services/repo/strategy_ql.go b/internal/services/repo/strategy_ql.go new file mode 100644 index 0000000..76aee2d --- /dev/null +++ b/internal/services/repo/strategy_ql.go @@ -0,0 +1,71 @@ +package repo + +import ( + "github.com/engigu/taskpool/internal/models" + "regexp" + "strings" +) + +// QinglongStrategy 实现与青龙兼容的解析逻辑 +type QinglongStrategy struct{} + +func (s *QinglongStrategy) SupportExtension(ext string, exts []string) bool { + for _, e := range exts { + if ext == e { + return true + } + } + return false +} + +func (s *QinglongStrategy) ShouldProcess(relRepoPath, filename string, cfg models.RepoConfig) bool { + // 只有在显式设置了白名单时才进行白名单校验 (青龙行为) + if cfg.WhitelistPaths != "" { + if !matchesQLPattern(relRepoPath, filename, cfg.WhitelistPaths) { + return false + } + } + + // 校验黑名单 + if cfg.Blacklist != "" { + if matchesQLPattern(relRepoPath, filename, cfg.Blacklist) { + return false + } + } + return true +} + +func (s *QinglongStrategy) ExtractMeta(path string, ext string, cfg models.RepoConfig) (taskName string, taskCron string) { + return ExtractScriptMeta(path, ext) +} + +// matchesQLPattern 应用关键字过滤逻辑(正则或包含匹配) +func matchesQLPattern(rel, filename string, keywordsStr string) bool { + if keywordsStr == "" { + return false + } + + keywords := splitKeywords(keywordsStr) + for _, k := range keywords { + // 1. 尝试作为正则整体进行匹配,默认不区分大小写 (?i) + pattern := k + if !strings.HasPrefix(pattern, "(?i)") { + pattern = "(?i)" + pattern + } + + reg, err := regexp.Compile(pattern) + if err == nil { + // 优先匹配文件名(解决 ^jd[^_] 这种锚点在相对路径下失效的问题) + if reg.MatchString(filename) || reg.MatchString(rel) { + return true + } + } else { + // 回退逻辑:全小写包含判断 + kLower := strings.ToLower(k) + if strings.Contains(strings.ToLower(rel), kLower) || strings.Contains(strings.ToLower(filename), kLower) { + return true + } + } + } + return false +} diff --git a/internal/services/repo/strategy_std.go b/internal/services/repo/strategy_std.go new file mode 100644 index 0000000..24d4044 --- /dev/null +++ b/internal/services/repo/strategy_std.go @@ -0,0 +1,31 @@ +package repo + +import ( + "github.com/engigu/taskpool/internal/models" +) + +// StandardStrategy 实现默认的解析逻辑 +type StandardStrategy struct{} + +func (s *StandardStrategy) SupportExtension(ext string, exts []string) bool { + for _, e := range exts { + if ext == e { + return true + } + } + return false +} + +func (s *StandardStrategy) ShouldProcess(relRepoPath, filename string, cfg models.RepoConfig) bool { + // 标准策略未来可能具有不同的过滤规则 + // 目前如果提供了白名单/黑名单,则遵循相同的逻辑,但使用更简单的匹配 + return true +} + +func (s *StandardStrategy) ExtractMeta(path string, ext string, cfg models.RepoConfig) (taskName string, taskCron string) { + // 仅在开启兼容 QL 配置时才解析脚本注释 + if cfg.CommentToTask == "true" { + return ExtractScriptMeta(path, ext) + } + return "", "" +} diff --git a/internal/services/repo/util.go b/internal/services/repo/util.go new file mode 100644 index 0000000..0d29aab --- /dev/null +++ b/internal/services/repo/util.go @@ -0,0 +1,251 @@ +package repo + +import ( + "bufio" + "fmt" + "github.com/engigu/taskpool/internal/utils" + "github.com/robfig/cron/v3" + "os" + "path/filepath" + "regexp" + "strings" +) + +var ( + // envRegex 匹配脚本中的环境名称设置,如 Env("名称") + envRegex = regexp.MustCompile(`(?i)(?:new[ \t]+)?Env\(['"]?([^'"]+)['"]?\)`) + // cronRegex 匹配脚本中的 cron 表达式设置 + cronRegex = regexp.MustCompile(`(?i)(?:cron[ \t]*[:=][ \t]*['"]?([^'"\r\n]+))|(?:(?:^|[ \t\*\/])(([0-9\*\/\-,L?#]+[ \t]+){4,5}[0-9\*\/\-,L?#]+))`) + // cronFormatRegex 用于校验提取出的字符串是否符合 Cron 表达式格式 (5位或6位) + cronFormatRegex = regexp.MustCompile(`^(([0-9\*\/\-,L?#]+)[ \t]+){4,5}([0-9\*\/\-,L?#]+)$`) +) + +// ExtractScriptMeta 读取文件以提取任务名称和 cron 表达式 +func ExtractScriptMeta(path string, ext string) (taskName string, taskCron string) { + f, err := os.Open(path) + if err != nil { + return "", "" + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var firstCommentLine string + inBlockComment := false + + // 特殊处理:针对当前文件名的 Cron 关联正则表达式 (对标青龙 perl 逻辑) + // 寻找类似 "// 0 0 * * * jd_task.js" 的行 + fileNameEscaped := regexp.QuoteMeta(filepath.Base(path)) + associatedCronRegex := regexp.MustCompile(fmt.Sprintf(`(?i)(?:^|[ \t\*\//])(([0-9\*\/\-,L?#]+[ \t]+){4,5}[0-9\*\/\-,L?#]+)[ \t,"]+.*%s`, fileNameEscaped)) + + for i := 0; i < 15 && scanner.Scan(); i++ { // 限制扫描范围,避免误匹配代码深处的属性 + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // 处理块注释开始/结束 + if strings.HasPrefix(line, "/*") { + inBlockComment = true + line = strings.TrimPrefix(line, "/*") + line = strings.TrimPrefix(line, "*") + line = strings.TrimSpace(line) + } + if strings.HasSuffix(line, "*/") { + inBlockComment = false + line = strings.TrimSuffix(line, "*/") + line = strings.TrimSpace(line) + } + + // 1. 尝试提取任务名称 (优先使用 Env) + if taskName == "" { + if envMatch := envRegex.FindStringSubmatch(line); len(envMatch) > 1 { + taskName = strings.TrimSpace(envMatch[1]) + } else if strings.Contains(line, "name:") { + // 兼容 name: "xxx" 格式 + nameRegex := regexp.MustCompile(`(?i)name:[ \t]*['"]([^'"]+)['"]`) + if nameMatch := nameRegex.FindStringSubmatch(line); len(nameMatch) > 1 { + taskName = strings.TrimSpace(nameMatch[1]) + } + } + } + + // 如果还没找到名称,且在注释中,记录第一行非空注释作为备选名称 + if taskName == "" && (inBlockComment || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "*") || strings.HasPrefix(line, "#")) { + cleanLine := line + if strings.HasPrefix(line, "//") { + cleanLine = strings.TrimPrefix(line, "//") + } else if strings.HasPrefix(line, "#") { + cleanLine = strings.TrimPrefix(line, "#") + } else if strings.HasPrefix(line, "*") { + cleanLine = strings.TrimPrefix(line, "*") + } + cleanLine = strings.TrimSpace(cleanLine) + + // 排除掉包含 "Env" 或 "cron" 的行, 且排除掉可能是路径或URL的行 + if cleanLine != "" && !strings.Contains(strings.ToLower(cleanLine), "env") && + !strings.Contains(strings.ToLower(cleanLine), "cron") && + !strings.Contains(cleanLine, "http") && + !strings.Contains(cleanLine, "/") && + firstCommentLine == "" { + // 且排除掉纯 cron 表达式 + if !cronRegex.MatchString(cleanLine) { + firstCommentLine = cleanLine + } + } + } + + // 2. 提取 Cron + if taskCron == "" { + // A. 优先查找关联了当前文件名的 Cron (对标 QL) + if assocMatch := associatedCronRegex.FindStringSubmatch(line); len(assocMatch) > 1 { + tempCron := strings.Trim(strings.TrimSpace(assocMatch[1]), "\"' \t") + if isLikelyCron(tempCron) { + taskCron = tempCron + } + } + + // B. 如果没找到,尝试普通的 cron: "..." 或 cron 表达式 + if taskCron == "" { + if cronMatch := cronRegex.FindStringSubmatch(line); len(cronMatch) > 0 { + for _, m := range cronMatch[1:] { + if m != "" { + tempCron := strings.Trim(strings.TrimSpace(m), "\"' \t") + if isLikelyCron(tempCron) { + taskCron = tempCron + break + } + } + } + } + } + } + + if taskName != "" && taskCron != "" { + break + } + } + + // 如果最后还是没找到 taskName,使用备选名称或文件名 + if taskName == "" { + if firstCommentLine != "" { + taskName = firstCommentLine + } else { + taskName = strings.TrimSuffix(filepath.Base(path), ext) + } + } + + return taskName, taskCron +} + +// isLikelyCron 校验字符串是否符合 Cron 表达式的格式特征且数值合法 +func isLikelyCron(s string) bool { + if !cronFormatRegex.MatchString(s) { + return false + } + + // 语义校验:确保数值在合法范围内(解决如 JS 数组数字超出 Cron 范围的问题) + fields := strings.Fields(s) + testCron := s + if len(fields) == 5 { + testCron = "0 " + s + } + + // 使用与面板执行器一致的 Parser 进行校验 + parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + _, err := parser.Parse(testCron) + return err == nil +} + +// splitKeywords 按竖线或逗号分割字符串 +func splitKeywords(s string) []string { + if s == "" { + return nil + } + var parts []string + if strings.Contains(s, "|") { + parts = strings.Split(s, "|") + } else if strings.Contains(s, ",") { + parts = strings.Split(s, ",") + } else { + parts = []string{s} + } + + var res []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + res = append(res, p) + } + } + return res +} + +// getValidExtensions 返回支持的文件扩展名列表,优先考虑自定义扩展名 +func getValidExtensions(customExtensions string) []string { + exts := []string{".js", ".py", ".ts", ".sh"} + if customExtensions != "" { + customExts := splitKeywords(customExtensions) + if len(customExts) > 0 { + exts = nil + for _, e := range customExts { + e = strings.TrimSpace(e) + if e != "" { + if !strings.HasPrefix(e, ".") { + e = "." + e + } + exts = append(exts, e) + } + } + } + } + return exts +} + +// sanitizeIdentifier 将非字母数字字符替换为下划线 +func sanitizeIdentifier(s string) string { + reg := regexp.MustCompile(`[^a-zA-Z0-9]+`) + res := reg.ReplaceAllString(s, "_") + return strings.ToLower(strings.Trim(res, "_")) +} + +// normalizeCron 确保 cron 表达式具有 6 个字段 +func normalizeCron(cron string) string { + fields := strings.Fields(cron) + if len(fields) == 5 { + return "0 " + cron + } + return cron +} + +// pathExists 检查路径是否存在 +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// isDir 检查路径是否为目录 +func isDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +// getCommandByExt 根据文件扩展名返回默认执行命令 +func getCommandByExt(ext, path string) string { + quotedPath := utils.QuotePath(path) + switch ext { + case ".js", ".ts": + return fmt.Sprintf("node %s", quotedPath) + case ".py": + return fmt.Sprintf("python %s", quotedPath) + case ".sh": + return fmt.Sprintf("bash %s", quotedPath) + case ".php": + return fmt.Sprintf("php %s", quotedPath) + case ".cs": + return fmt.Sprintf("dotnet run %s", quotedPath) + } + return quotedPath +} diff --git a/internal/services/script_service.go b/internal/services/script_service.go new file mode 100644 index 0000000..702b16e --- /dev/null +++ b/internal/services/script_service.go @@ -0,0 +1,56 @@ +package services + +import ( + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +type ScriptService struct{} + +func NewScriptService() *ScriptService { + return &ScriptService{} +} + +func (ss *ScriptService) CreateScript(name, content string, userID string) *models.Script { + script := &models.Script{ + ID: utils.GenerateID(), + Name: name, + Content: models.BigText(content), + UserID: userID, + } + database.DB.Create(script) + return script +} + +func (ss *ScriptService) GetScriptsByUserID(userID string) []models.Script { + var scripts []models.Script + database.DB.Where("user_id = ?", userID).Find(&scripts) + return scripts +} + +func (ss *ScriptService) GetScriptByID(id string) *models.Script { + var script models.Script + res := database.DB.Where("id = ?", id).Limit(1).Find(&script) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + return &script +} + +func (ss *ScriptService) UpdateScript(id string, name, content string) *models.Script { + var script models.Script + res := database.DB.Where("id = ?", id).Limit(1).Find(&script) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + script.Name = name + script.Content = models.BigText(content) + database.DB.Save(&script) + return &script +} + +func (ss *ScriptService) DeleteScript(id string) bool { + result := database.DB.Where("id = ?", id).Delete(&models.Script{}) + return result.RowsAffected > 0 +} diff --git a/internal/services/send_stats_service.go b/internal/services/send_stats_service.go new file mode 100644 index 0000000..3b0a100 --- /dev/null +++ b/internal/services/send_stats_service.go @@ -0,0 +1,61 @@ +package services + +import ( + "time" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/systime" + "github.com/engigu/taskpool/internal/utils" +) + +type SendStatsService struct{} + +func NewSendStatsService() *SendStatsService { + return &SendStatsService{} +} + +// IncrementStats 增加任务执行统计 +func (s *SendStatsService) IncrementStats(taskID string, status string) error { + day := systime.FormatDate(time.Now()) + + var stats models.SendStats + res := database.DB.Where("task_id = ? AND day = ? AND status = ?", taskID, day, status).Limit(1).Find(&stats) + if res.Error != nil || res.RowsAffected == 0 { + // 不存在则创建 + stats = models.SendStats{ + ID: utils.GenerateID(), + TaskID: taskID, + Day: day, + Status: status, + Num: 1, + } + return database.DB.Create(&stats).Error + } + + // 存在则增加计数 + return database.DB.Model(&stats).Update("num", stats.Num+1).Error +} + +// GetStatsByTaskID 获取任务的统计数据 +func (s *SendStatsService) GetStatsByTaskID(taskID string) []models.SendStats { + var stats []models.SendStats + database.DB.Where("task_id = ?", taskID).Order("day DESC").Find(&stats) + return stats +} + +// GetTodayStats 获取今日统计 +func (s *SendStatsService) GetTodayStats() []models.SendStats { + day := systime.FormatDate(time.Now()) + var stats []models.SendStats + database.DB.Where("day = ?", day).Find(&stats) + return stats +} + +// GetRecentStats 获取最近N天的统计 +func (s *SendStatsService) GetRecentStats(days int) []models.SendStats { + startDay := systime.FormatDate(time.Now().AddDate(0, 0, -days)) + var stats []models.SendStats + database.DB.Where("day >= ?", startDay).Order("day DESC").Find(&stats) + return stats +} diff --git a/internal/services/settings_service.go b/internal/services/settings_service.go new file mode 100644 index 0000000..2b8d2e4 --- /dev/null +++ b/internal/services/settings_service.go @@ -0,0 +1,204 @@ +package services + +import ( + "encoding/json" + "fmt" + + "github.com/engigu/taskpool/internal/cache" + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" +) + +type SettingsService struct{} + +func NewSettingsService() *SettingsService { + return &SettingsService{} +} + +// InitSettings 初始化默认设置 +func (s *SettingsService) InitSettings() error { + for section, keys := range constant.DefaultSettings { + for key, value := range keys { + var count int64 + database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: section, Key: key}).Count(&count) + if count == 0 { + if err := database.DB.Create(&models.Setting{ + ID: utils.GenerateID(), + Section: section, + Key: key, + Value: models.BigText(value), + }).Error; err != nil { + return err + } + } + } + } + + // 初始化日志清理配置 + // 检查是否需要从旧的 JSON 迁移 + oldVal := s.Get(constant.SectionSystem, "log_retention") + if oldVal != "" { + var oldConfigs map[string]struct { + Days int `json:"days"` + MaxCount int `json:"max_count"` + } + if err := json.Unmarshal([]byte(oldVal), &oldConfigs); err == nil { + migrationMap := map[string]string{} + if cfg, ok := oldConfigs[constant.LogCategorySystemNotice]; ok { + migrationMap[constant.KeySystemNoticeDays] = fmt.Sprintf("%d", cfg.Days) + migrationMap[constant.KeySystemNoticeMaxCount] = fmt.Sprintf("%d", cfg.MaxCount) + } + if cfg, ok := oldConfigs[constant.LogCategoryPushLog]; ok { + migrationMap[constant.KeyPushLogDays] = fmt.Sprintf("%d", cfg.Days) + migrationMap[constant.KeyPushLogMaxCount] = fmt.Sprintf("%d", cfg.MaxCount) + } + if cfg, ok := oldConfigs[constant.LogCategoryLoginLog]; ok { + migrationMap[constant.KeyLoginLogDays] = fmt.Sprintf("%d", cfg.Days) + migrationMap[constant.KeyLoginLogMaxCount] = fmt.Sprintf("%d", cfg.MaxCount) + } + + if len(migrationMap) > 0 { + for k, v := range migrationMap { + s.Set(constant.SectionSystem, k, v) + } + // 迁移完成后删除旧键 + s.Delete(constant.SectionSystem, "log_retention") + } + } + } + + // 默认值初始化 + defaultRetention := map[string]string{ + constant.KeySystemNoticeDays: "30", + constant.KeySystemNoticeMaxCount: "500", + constant.KeyPushLogDays: "15", + constant.KeyPushLogMaxCount: "5000", + constant.KeyLoginLogDays: "30", + constant.KeyLoginLogMaxCount: "1000", + constant.KeySchedulerLogDays: "30", + constant.KeySchedulerLogMaxCount: "10000", + } + + for k, v := range defaultRetention { + var count int64 + database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionSystem, Key: k}).Count(&count) + if count == 0 { + s.Set(constant.SectionSystem, k, v) + } + } + + // 从 constant.DefaultSettings 初始化所有缺少的通知模板 + if notifyDefaults, ok := constant.DefaultSettings[constant.SectionNotify]; ok { + for k, v := range notifyDefaults { + var count int64 + database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionNotify, Key: k}).Count(&count) + if count == 0 { + s.Set(constant.SectionNotify, k, v) + } + } + } + + // 初始化或获取 JWT Secret 密码 + var secCount int64 + database.DB.Model(&models.Setting{}).Where(&models.Setting{Section: constant.SectionSecurity, Key: constant.KeySecret}).Count(&secCount) + var secretValue string + if secCount == 0 { + // 先尝试从配置文件读取遗留下来的旧设 + if Config != nil && Config.Security.Secret != "" { + secretValue = Config.Security.Secret + } else { + secretValue = utils.RandomString(32) + } + if err := database.DB.Create(&models.Setting{ + ID: utils.GenerateID(), + Section: constant.SectionSecurity, + Key: constant.KeySecret, + Value: models.BigText(secretValue), + }).Error; err != nil { + return err + } + } else { + secretValue = s.Get(constant.SectionSecurity, constant.KeySecret) + } + constant.Secret = secretValue + + cache.LoadSiteCache() + return nil +} + +// Get 获取单个设置 +func (s *SettingsService) Get(section, key string) string { + if section == constant.SectionSite { + return cache.GetSiteCache(key) + } + var setting models.Setting + res := database.DB.Where(&models.Setting{Section: section, Key: key}).Limit(1).Find(&setting) + if res.Error != nil || res.RowsAffected == 0 { + if def, ok := constant.DefaultSettings[section][key]; ok { + return def + } + return "" + } + return string(setting.Value) +} + +// Set 设置单个值 +func (s *SettingsService) Set(section, key, value string) error { + var setting models.Setting + res := database.DB.Where(&models.Setting{Section: section, Key: key}).Limit(1).Find(&setting) + var err error + if res.Error != nil || res.RowsAffected == 0 { + err = database.DB.Create(&models.Setting{ + ID: utils.GenerateID(), + Section: section, + Key: key, + Value: models.BigText(value), + }).Error + } else { + err = database.DB.Model(&setting).Update("value", models.BigText(value)).Error + } + + if err == nil && section == constant.SectionSite { + cache.SetSiteCache(key, value) + } + return err +} + +// Delete 删除单个设置 +func (s *SettingsService) Delete(section, key string) error { + return database.DB.Where(&models.Setting{Section: section, Key: key}).Delete(&models.Setting{}).Error +} + +// GetSection 获取整个 section 的设置 +func (s *SettingsService) GetSection(section string) map[string]string { + if section == constant.SectionSite { + return cache.GetSiteCacheAll() + } + result := make(map[string]string) + if defaults, ok := constant.DefaultSettings[section]; ok { + for k, v := range defaults { + result[k] = v + } + } + var settings []models.Setting + database.DB.Where("section = ?", section).Find(&settings) + for _, setting := range settings { + result[setting.Key] = string(setting.Value) + } + return result +} + +// SetSection 批量设置 +func (s *SettingsService) SetSection(section string, values map[string]string) error { + for key, value := range values { + if err := s.Set(section, key, value); err != nil { + return err + } + } + if section == constant.SectionSite { + cache.SetSiteCacheBatch(values) + } + return nil +} diff --git a/internal/services/system_ws_service.go b/internal/services/system_ws_service.go new file mode 100644 index 0000000..f736ad8 --- /dev/null +++ b/internal/services/system_ws_service.go @@ -0,0 +1,132 @@ +package services + +import ( + "encoding/json" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models/vo" + + "github.com/gorilla/websocket" +) + +// SystemWSManager 前端系统事件 WebSocket 管理器 (单例) +type SystemWSManager struct { + clients map[*ClientConnection]bool + mu sync.RWMutex +} + +// ClientConnection 代表一个前端页面的 WebSocket 连接 +type ClientConnection struct { + Conn *websocket.Conn + Send chan []byte + closed bool + mu sync.Mutex +} + +var systemWSManager *SystemWSManager +var systemWSOnce sync.Once + +// GetSystemWSManager 获取系统 WebSocket 管理器单例 +func GetSystemWSManager() *SystemWSManager { + systemWSOnce.Do(func() { + systemWSManager = &SystemWSManager{ + clients: make(map[*ClientConnection]bool), + } + }) + return systemWSManager +} + +// Register 注册一个新的前端连接 +func (m *SystemWSManager) Register(conn *websocket.Conn) *ClientConnection { + m.mu.Lock() + defer m.mu.Unlock() + + client := &ClientConnection{ + Conn: conn, + Send: make(chan []byte, 256), + } + m.clients[client] = true + return client +} + +// Unregister 注销一个前端连接 +func (m *SystemWSManager) Unregister(client *ClientConnection) { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.clients[client]; ok { + delete(m.clients, client) + client.Close() + } +} + +// Broadcast 广播消息给所有在线前端 +func (m *SystemWSManager) Broadcast(msgType string, payload interface{}) { + msg := vo.WSMessage{ + Type: msgType, + Timestamp: time.Now().UnixMilli(), + Payload: payload, + } + + data, err := json.Marshal(msg) + if err != nil { + logger.Errorf("[SystemWS] 序列化消息失败: %v", err) + return + } + + m.mu.RLock() + defer m.mu.RUnlock() + + for client := range m.clients { + select { + case client.Send <- data: + default: + // 缓冲区满,可能该客户端连接已死 + go m.Unregister(client) + } + } +} + +// SubscribeEvents 订阅系统事件总线并分发给 WebSocket +func (m *SystemWSManager) SubscribeEvents(bus *eventbus.EventBus) { + // 任务相关事件 + taskEvents := []string{ + constant.EventTaskSuccess, + constant.EventTaskFailed, + constant.EventTaskTimeout, + constant.EventTaskRunning, + constant.EventTaskQueued, + constant.EventTaskCancelled, + } + + for _, evt := range taskEvents { + bus.Subscribe(evt, func(e eventbus.Event) { + m.Broadcast(e.Type, e.Payload) + }) + } + + // 系统通知事件 + bus.Subscribe(constant.EventSystemNotice, func(e eventbus.Event) { + m.Broadcast("notice", e.Payload) + }) + + // 应用日志新增事件(驱动运行日志下属4大标签页实时流式刷新列表) + bus.Subscribe(constant.EventAppLogAdded, func(e eventbus.Event) { + m.Broadcast(e.Type, e.Payload) + }) +} + +func (c *ClientConnection) Close() { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return + } + c.closed = true + c.Conn.Close() + close(c.Send) +} diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go new file mode 100644 index 0000000..a200ea0 --- /dev/null +++ b/internal/services/tasks/executor_service.go @@ -0,0 +1,1374 @@ +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/eventbus" + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" + + "gorm.io/gorm" +) + +// AgentWSManager 接口定义(避免循环依赖) +type AgentWSManager interface { + RegisterRemoteWaiter(logID string) chan *models.AgentTaskResult + UnregisterRemoteWaiter(logID string) + SendToAgent(agentID string, msgType string, data interface{}) error + IsAgentOnline(agentID string) bool +} + +// SettingsService 接口定义(避免循环依赖) +type SettingsService interface { + Get(section, key string) string +} + +// EnvService 接口定义(避免循环依赖) +type EnvService interface { + GetEnvVarsByIDs(ids string) []string + GetAllEnvVars() []string + GetEnvVarsAndSecretsByIDs(ids string) ([]string, []string) + GetAllEnvVarsAndSecrets() ([]string, []string) +} + +type ExecutorService struct { + taskService *TaskService + taskLogService *TaskLogService + agentWSManager AgentWSManager + settingsService SettingsService + envService EnvService + scheduler *executor.Scheduler + cronManager *executor.CronManager + results []executor.ExecutionResult + mu sync.RWMutex + resultsMu sync.RWMutex + stopCh chan struct{} +} + +func (es *ExecutorService) GetScheduler() *executor.Scheduler { + return es.scheduler +} + +func NewExecutorService( + taskService *TaskService, + taskLogService *TaskLogService, + agentWSManager AgentWSManager, + settingsService SettingsService, + envService EnvService, +) *ExecutorService { + // 0. 清理旧临时日志 + CleanupOrphanedTinyLogs() + + es := &ExecutorService{ + taskService: taskService, + taskLogService: taskLogService, + agentWSManager: agentWSManager, + settingsService: settingsService, + envService: envService, + results: make([]executor.ExecutionResult, 0, 100), + stopCh: make(chan struct{}), + } + + // 1. 初始化调度器 + es.initScheduler() + + // 2. 初始化计划任务管理器 + es.cronManager = executor.NewCronManager(es.scheduler) + es.cronManager.OnTrigger = func(t executor.CronTask) *executor.ExecutionRequest { + task := es.taskService.GetTaskByID(t.GetID()) + return es.CreateExecutionRequest(task, executor.TaskTypeCron, nil) + } + + return es +} + +func (es *ExecutorService) initScheduler() { + workerCount := getIntSetting(es.settingsService, constant.SectionScheduler, constant.KeyWorkerCount, 4) + queueSize := getIntSetting(es.settingsService, constant.SectionScheduler, constant.KeyQueueSize, 100) + rateInterval := getIntSetting(es.settingsService, constant.SectionScheduler, constant.KeyRateInterval, 200) + + config := executor.SchedulerConfig{ + WorkerCount: workerCount, + QueueSize: queueSize, + RateInterval: time.Duration(rateInterval) * time.Millisecond, + } + + handler := &ServerSchedulerHandler{es: es} + es.scheduler = executor.NewScheduler(config, handler) + es.scheduler.SetLogger(logger.NewSchedulerLogger()) + es.scheduler.SetExecutor(es.ExecuteDispatcher) + es.scheduler.Start() + + logger.Infof("[Executor] 调度器已启动: workers=%d, queue=%d, rate=%dms", workerCount, queueSize, rateInterval) + + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventSchedulerLog, + Payload: map[string]interface{}{ + "title": "调度器启动", + "content": fmt.Sprintf("主服务任务调度引擎已成功拉起运行。\n配置 Worker 数量: %d\n等待队列容量: %d\n速率限制间隔: %dms", workerCount, queueSize, rateInterval), + "level": constant.LogLevelInfo, + }, + }) +} + +// ServerSchedulerHandler 实现 executor.SchedulerEventHandler +type ServerSchedulerHandler struct { + es *ExecutorService +} + +func (h *ServerSchedulerHandler) OnTaskScheduled(req *executor.ExecutionRequest) { + if req.TaskID != "" { + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventTaskQueued, + Payload: map[string]interface{}{ + "task_id": req.TaskID, + "status": constant.TaskStatusQueued, + }, + }) + + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventSchedulerLog, + Payload: map[string]interface{}{ + "title": "触发任务", + "content": fmt.Sprintf("任务 [%s] (#%s) 调度类型: %s\n已成功推入后台调度排队队列等待执行。", req.Name, req.TaskID, req.Type), + "level": constant.LogLevelInfo, + }, + }) + } +} + +func (h *ServerSchedulerHandler) OnTaskExecuting(req *executor.ExecutionRequest) (io.Writer, io.Writer, error) { + taskID := req.TaskID + + task := h.es.taskService.GetTaskByID(taskID) + // 系统任务(无 taskID)不记录数据库日志,直接返回空写入器 + if task == nil { + return nil, nil, nil + } + + // 1. 使用预先准备好的脱敏指令创建初始日志记录 + taskLog, err := h.es.taskLogService.CreateEmptyLog(task.ID, req.MaskedCommand) + if err != nil { + return nil, nil, fmt.Errorf("创建初始日志失败: %v", err) + } + req.LogID = taskLog.ID // 设置 LogID 供后续环节使用 + + // 2. 检查并记录运行状态(并发控制) + goid, err := h.es.AddRunningGo(task.ID) + if err != nil { + // 并发限制,更新日志状态为失败 + taskLog.Status = constant.TaskStatusFailed + comp, _ := utils.CompressToBase64("任务并发数限制,拒绝执行") + taskLog.Output = models.BigText(comp) + h.es.taskLogService.SaveTaskLog(taskLog) + return nil, nil, fmt.Errorf("任务并发限制: %v", err) + } + + req.Metadata.GoID = goid + + // 3. 创建 TinyLog 实时日志收集器 + tl, err := NewTinyLog(taskLog.ID, req.Secrets) + if err != nil { + h.es.RemoveRunningGo(task.ID, goid) // 回滚运行状态 + return nil, nil, fmt.Errorf("创建日志收集器失败: %v", err) + } + + // 记录到内存缓冲 + h.es.UpdateResult(executor.ExecutionResult{ + TaskID: req.TaskID, + LogID: req.LogID, + Status: constant.TaskStatusRunning, + StartTime: time.Now(), + }) + + // 发布任务开始运行事件 + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventTaskRunning, + Payload: map[string]interface{}{ + "task_id": req.TaskID, + "status": constant.TaskStatusRunning, + "log_id": req.LogID, + }, + }) + + if req.Metadata.RetryIndex > 0 { + tl.Write([]byte(fmt.Sprintf("\n[System] 此为任务失败后的第 %d 次重试执行...\n\n", req.Metadata.RetryIndex))) + } + + // 对于本地任务,Scheduler 会通过返回的 Writer 写入日志 + // 对于远程任务,Scheduler 不会写入任何内容(由 Agent 推送至此 TL) + return tl, tl, nil +} + +func (h *ServerSchedulerHandler) OnTaskHeartbeat(req *executor.ExecutionRequest, duration int64) { + if req.LogID != "" { + h.es.taskLogService.UpdateTaskDuration(req.LogID, duration) + } + + // 每分钟打印一次任务还在运行的日志 + if duration >= 60000 && (duration/60000 > (duration-3000)/60000) { + logger.Infof("[Scheduler] 命令: %s (#%s 已耗时: %v)", + req.MaskedCommand, req.TaskID, (time.Duration(duration) * time.Millisecond).Round(time.Second)) + } +} + +func (h *ServerSchedulerHandler) OnTaskStarted(req *executor.ExecutionRequest) { + // Logic moved to OnTaskExecuting +} + +func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest, result *executor.ExecutionResult) { + if req.LogID == "" { + return + } + + taskID := req.TaskID + + task := h.es.taskService.GetTaskByID(taskID) + if task == nil { + return + } + + // 无论本地还是远程,都在此处处理日志压缩和落库 + tl := GetActiveLog(req.LogID) + var output string + if tl != nil { + // 压缩并清理实时日志 + var err error + output, err = tl.CompressAndCleanup() + if err != nil { + logger.Errorf("[Executor] 压缩任务 #%s 日志失败: %v", task.ID, err) + output = "[System Error] 日志处理失败: " + err.Error() + } + } else { + // 如果 TinyLog 已经丢失,尝试从 result.Output 中恢复一次(主要针对本地任务) + output, _ = utils.CompressToBase64(result.Output) + } + + // 构造待保存的日志模型 + startTime := models.LocalTime(result.StartTime) + endTime := models.LocalTime(result.EndTime) + + taskLog := &models.TaskLog{ + ID: req.LogID, + TaskID: task.ID, + Command: models.BigText(req.MaskedCommand), + Output: models.BigText(output), + Error: models.BigText(result.Error), + Status: result.Status, + Duration: result.Duration, + ExitCode: result.ExitCode, + StartTime: &startTime, + EndTime: &endTime, + } + + // 如果有 AgentID,也记录下来 + if task.AgentID != nil && *task.AgentID != "" { + agentID := *task.AgentID + taskLog.AgentID = &agentID + } + + // 移除运行记录 + if req.Metadata.GoID != 0 { + h.es.RemoveRunningGo(task.ID, req.Metadata.GoID) + } + + // 处理任务完成(更新统计、清理旧日志等) + h.es.taskLogService.ProcessTaskCompletion(taskLog) + + // 更新内存缓冲 + h.es.UpdateResult(*result) + + // ======= 重试逻辑 ======= + h.es.HandleTaskRetry(task, req, result.Success, result.Status, result.ExitCode) + + // ======= 通知触发 ======= + // ======= 通知触发 ======= + go func() { + var eventType string + switch result.Status { + case constant.TaskStatusSuccess: + eventType = constant.EventTaskSuccess + case constant.TaskStatusFailed: + eventType = constant.EventTaskFailed + case constant.TaskStatusTimeout: + eventType = constant.EventTaskTimeout + case constant.TaskStatusCancelled: + eventType = constant.EventTaskCancelled + } + if eventType != "" { + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: eventType, + Payload: map[string]interface{}{ + "log_id": req.LogID, + "task_id": task.ID, + "task_name": task.Name, + "status": result.Status, + "start_time": result.StartTime.Format("2006-01-02 15:04:05"), + "duration": result.Duration, + "output": result.Output, + "error": result.Error, + }, + }) + } + }() +} + +func (h *ServerSchedulerHandler) OnTaskFailed(req *executor.ExecutionRequest, err error) { + if req.LogID == "" { + return + } + + taskID := req.TaskID + + // 移除运行记录 + if req.Metadata.GoID != 0 { + h.es.RemoveRunningGo(taskID, req.Metadata.GoID) + } + + // 构造错误日志 + tl := GetActiveLog(req.LogID) + var output string + if tl != nil { + tl.Write([]byte(fmt.Sprintf("\n[System Error] %v", err))) + output, _ = tl.CompressAndCleanup() + } else { + output, _ = utils.CompressToBase64(fmt.Sprintf("任务执行失败: %v", err)) + } + + now := models.LocalTime(time.Now()) + taskLog := &models.TaskLog{ + ID: req.LogID, + TaskID: taskID, + Command: models.BigText(req.MaskedCommand), + Output: models.BigText(output), + Error: models.BigText(err.Error()), + Status: constant.TaskStatusFailed, + Duration: 0, + ExitCode: 1, + StartTime: &now, + EndTime: &now, + } + + // 补充 AgentID + task := h.es.taskService.GetTaskByID(taskID) + if task != nil && task.AgentID != nil && *task.AgentID != "" { + agentID := *task.AgentID + taskLog.AgentID = &agentID + } + + h.es.taskLogService.ProcessTaskCompletion(taskLog) + + // 更新内存缓冲 + h.es.UpdateResult(executor.ExecutionResult{ + TaskID: req.TaskID, + LogID: req.LogID, + Status: constant.TaskStatusFailed, + Error: err.Error(), + StartTime: time.Now(), + EndTime: time.Now(), + }) + + // ======= 重试逻辑 ======= + h.es.HandleTaskRetry(task, req, false, constant.TaskStatusFailed, 1) + + // ======= 通知触发 ======= + // ======= 通知触发 ======= + go func() { + taskName := "未知任务" + if task != nil { + taskName = task.Name + } + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventTaskFailed, + Payload: map[string]interface{}{ + "log_id": req.LogID, + "task_id": taskID, + "task_name": taskName, + "error": err.Error(), + "output": output, + }, + }) + }() +} + +// HandleTaskRetry 处理任务失败重试逻辑 +func (es *ExecutorService) HandleTaskRetry(task *models.Task, req *executor.ExecutionRequest, isSuccess bool, status string, exitCode int) { + if task == nil { + return + } + + if !isSuccess || status == constant.TaskStatusFailed || status == constant.TaskStatusTimeout || exitCode != 0 { + retryIndex := req.Metadata.RetryIndex + + if retryIndex < task.RetryCount { + retryIndex++ + logger.Infof("[Executor] 任务 #%s 执行失败/出错,将在 %d 秒后进行第 %d/%d 次重试...", task.ID, task.RetryInterval, retryIndex, task.RetryCount) + + es.scheduler.EnqueueDelayed(time.Duration(task.RetryInterval)*time.Second, func() *executor.ExecutionRequest { + latestTask := es.taskService.GetTaskByID(task.ID) + if latestTask == nil || !utils.DerefBool(latestTask.Enabled, true) { + return nil + } + newReq := es.CreateExecutionRequest(latestTask, req.Type, nil) + newReq.Metadata.RetryIndex = retryIndex + return newReq + }) + } + } +} + +func (h *ServerSchedulerHandler) OnCronNextRun(req *executor.ExecutionRequest, nextRun time.Time) { + taskID := req.TaskID + // 更新数据库中的下次运行时间 + database.DB.Model(&models.Task{}).Where("id = ?", taskID).Update("next_run", nextRun) +} + +// LocalTaskHooks 本地任务钩子适配器 +type LocalTaskHooks struct { + es *ExecutorService + logID string +} + +func (h *LocalTaskHooks) PreExecute(ctx context.Context, req executor.Request) (string, error) { + return h.logID, nil +} + +func (h *LocalTaskHooks) PostExecute(ctx context.Context, logID string, result *executor.Result) error { + return nil +} + +func (h *LocalTaskHooks) OnHeartbeat(ctx context.Context, logID string, duration int64) error { + if logID != "" { + return h.es.taskLogService.UpdateTaskDuration(logID, duration) + } + return nil +} + +// ExecuteDispatcher 实现任务分发逻辑 +func (es *ExecutorService) ExecuteDispatcher(ctx context.Context, req *executor.ExecutionRequest, stdout, stderr io.Writer) (*executor.Result, error) { + taskID := req.TaskID + + // 解析路径变量 (如 $SCRIPTS_DIR$) + req.Command = es.ResolvePath(req.Command) + req.PreCommand = es.ResolvePath(req.PreCommand) + req.PostCommand = es.ResolvePath(req.PostCommand) + req.WorkDir = es.ResolvePath(req.WorkDir) + + task := es.taskService.GetTaskByID(taskID) + // 系统任务(无 taskID)直接本地执行 + if task == nil { + return executor.Execute(ctx, executor.Request{ + Command: req.Command, + PreCommand: req.PreCommand, + PostCommand: req.PostCommand, + WorkDir: req.WorkDir, + Envs: req.Envs, + Timeout: req.Timeout, + UseMise: false, // 系统任务不使用 mise + }, stdout, stderr) + } + + // 重新加载最新的环境变量,满足即时生效的需求 + es.refreshExecutionRequestEnvs(req, task) + + // 在控制台打印最终执行的脱敏命令 + logger.Infof("[Executor] 任务最终执行命令: %s", req.MaskedCommand) + + // 组合指令逻辑已移至 executor.ExecuteWithHooks 中,此处不再处理 + // 以避免指令被重复组合。 + + // 远程任务 + if task.AgentID != nil && *task.AgentID != "" { + // 将请求中已包含的环境变量(已合并)传递给 Agent + return es.ExecuteRemoteForScheduler(ctx, task, req.LogID, executor.FormatEnvVars(req.Envs), req.Secrets) + } + + // 本地任务 + hooks := &LocalTaskHooks{es: es, logID: req.LogID} + return executor.ExecuteWithHooks(ctx, executor.Request{ + Command: req.Command, + PreCommand: req.PreCommand, + PostCommand: req.PostCommand, + WorkDir: req.WorkDir, + Envs: req.Envs, + Timeout: req.Timeout, + Languages: []map[string]string(task.Languages), + UseMise: req.UseMise, // 使用请求中的 UseMise 标志 (由调度器统一处理过) + }, stdout, stderr, hooks) +} + +// getIntSetting 从设置中获取整数值 +func getIntSetting(s SettingsService, section, key string, defaultVal int) int { + val := s.Get(section, key) + if val == "" { + return defaultVal + } + var result int + if _, err := fmt.Sscanf(val, "%d", &result); err != nil { + return defaultVal + } + return result +} + +// Stop 停止 executor service +func (es *ExecutorService) Stop() { + es.StopCron() + es.scheduler.Stop() +} + +// StartCron 启动计划任务 +func (es *ExecutorService) StartCron() { + go es.loadCronTasks() + es.cronManager.Start() + // logger.Info("[Executor] 计划任务管理器已启动") +} + +// StopCron 停止计划任务 +func (es *ExecutorService) StopCron() { + es.cronManager.Stop() + // logger.Info("[Executor] 计划任务管理器已停止") +} + +// AddCronTask 添加计划任务 +func (es *ExecutorService) AddCronTask(task *models.Task) error { + if task.TriggerType != constant.TriggerTypeCron { + es.RemoveCronTask(task.ID) // 如果不是cron类型,确保从调度器移除 + return nil + } + // 在加入调度器前,预先加载好环境信息 + task.RuntimeEnvs, task.RuntimeSecrets = es.loadEnvVars(task.ID, string(task.Envs)) + + return es.cronManager.AddTask(task) +} + +// RemoveCronTask 移除计划任务 +func (es *ExecutorService) RemoveCronTask(taskID string) { + es.cronManager.RemoveTask(taskID) +} + +// ValidateCron 验证 Cron 表达式 +func (es *ExecutorService) ValidateCron(expression string) error { + return es.cronManager.ValidateCron(expression) +} + +// GetScheduledCount 获取已加载的计划任务数量 +func (es *ExecutorService) GetScheduledCount() int { + return es.cronManager.GetScheduledCount() +} + +// loadCronTasks 加载所有已启用的本地计划任务 +func (es *ExecutorService) loadCronTasks() { + tasks := es.taskService.GetTasks() + count := 0 + for _, task := range tasks { + if !utils.DerefBool(task.Enabled, true) { + continue + } + + // taskpool_startup 为新协议值;baihu_startup 兼容存量任务 + if task.TriggerType == constant.TriggerTypeTaskPoolStartup || task.TriggerType == "baihu_startup" { + go func(t models.Task) { + // 延迟一点时间再触发,确保系统完全启动 + time.Sleep(3 * time.Second) + logger.Infof("[Executor] 触发开机服务启动任务 #%s: %s", t.ID, t.Name) + es.ExecuteTask(t.ID, nil) + }(task) + } else if task.TriggerType == constant.TriggerTypeCron && task.Schedule != "" && (task.AgentID == nil || *task.AgentID == "") { + // 只调度本地任务(agent_id 为空或 0)的定时任务 + err := es.AddCronTask(&task) + if err != nil { + continue + } + count++ + } + } + logger.Infof("[Executor] 启动调度已加载 %d 个定时任务", count) + + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventSchedulerLog, + Payload: map[string]interface{}{ + "title": "加载定时任务", + "content": fmt.Sprintf("后台调度引擎成功解析并载入本地活跃定时计划任务: %d 个。", count), + "level": constant.LogLevelInfo, + }, + }) +} + +// Reload 重新加载配置并重建调度器 +func (es *ExecutorService) Reload() { + logger.Info("[Executor] 正在重载配置...") + es.scheduler.Stop() + + // 从设置中读取新配置 + es.initScheduler() + + // 重要:更新计划任务管理器中的调度器引用,确保后续触发的任务进入新队列 + if es.cronManager != nil { + es.cronManager.SetScheduler(es.scheduler) + } + + eventbus.DefaultBus.Publish(eventbus.Event{ + Type: constant.EventSchedulerLog, + Payload: map[string]interface{}{ + "title": "调度器重载", + "content": "主服务任务调度引擎配置及执行队列实例已全量重载完毕。", + "level": constant.LogLevelInfo, + }, + }) +} + +// CreateExecutionRequest 统一处理任务到执行请求的转换逻辑(包含指令拼装、脱敏等) +func (es *ExecutorService) CreateExecutionRequest(task *models.Task, triggerType executor.TaskType, extraEnvs []string) *executor.ExecutionRequest { + if task == nil { + return nil + } + + // 1. 加载环境变量和机密 + envs, secrets := es.loadEnvVars(task.ID, string(task.Envs)) + if len(extraEnvs) > 0 { + envs = append(envs, extraEnvs...) + } + + // 2. 准备指令 + command := string(task.Command) + preCommand := string(task.PreCommand) + postCommand := string(task.PostCommand) + workDir := task.WorkDir + + // 解析路径变量 (如 $SCRIPTS_DIR$) + command = es.ResolvePath(command) + preCommand = es.ResolvePath(preCommand) + postCommand = es.ResolvePath(postCommand) + workDir = es.ResolvePath(workDir) + + useMise := task.UseMise() + + // 特殊处理仓库同步任务 + if task.Type == constant.TaskTypeRepo { + repoCmd, repoWorkDir := es.BuildRepoCommand(task) + if repoCmd != "" { + command = repoCmd + workDir = repoWorkDir + useMise = false // 仓库同步不使用 mise + // 仓库任务的前置/后置命令由 reposync 内部处理,此处清空 + preCommand = "" + postCommand = "" + + // 补充仓库特有的 AuthToken 到脱敏列表 + var repoCfg models.RepoConfig + if err := json.Unmarshal([]byte(task.Config), &repoCfg); err == nil && repoCfg.AuthToken != "" { + secrets = append(secrets, repoCfg.AuthToken) + } + } + } + + // 3. 统一脱敏处理 + masks := append([]string{}, secrets...) + masks = append(masks, utils.GetSystemSecrets()...) + maskedCommand := utils.MaskSecrets(command, masks) + + return &executor.ExecutionRequest{ + TaskID: task.ID, + Name: task.Name, + Type: triggerType, + Command: command, + MaskedCommand: maskedCommand, + PreCommand: preCommand, + PostCommand: postCommand, + WorkDir: workDir, + Envs: envs, + Secrets: secrets, + Timeout: task.Timeout, + Languages: []map[string]string(task.Languages), + UseMise: useMise, + } +} + +// ExecuteTask executes a task by ID(同步执行,供 API 调用) +func (es *ExecutorService) ExecuteTask(taskID string, extraEnvs []string) *executor.ExecutionResult { + task := es.taskService.GetTaskByID(taskID) + if task == nil { + return &executor.ExecutionResult{ + TaskID: taskID, + Success: false, + Error: "任务不存在", + StartTime: time.Now(), + EndTime: time.Now(), + } + } + + // 1. 检查并发 + if err := es.CheckConcurrency(taskID); err != nil { + return &executor.ExecutionResult{ + TaskID: taskID, + Success: false, + Error: err.Error(), // 这里会返回 "任务正在运行中,拒绝并行执行" + StartTime: time.Now(), + EndTime: time.Now(), + } + } + + req := es.CreateExecutionRequest(task, executor.TaskTypeManual, extraEnvs) + es.scheduler.EnqueueOrExecute(req) + + return &executor.ExecutionResult{ + TaskID: task.ID, + Success: true, + Status: constant.TaskStatusQueued, + StartTime: time.Now(), + } +} + +// SyncRepoTasks 增量同步仓库任务到调度器 +func (es *ExecutorService) SyncRepoTasks(upsertedIDs []string, deletedIDs []string) { + // 处理删除的任务 + for _, id := range deletedIDs { + es.RemoveCronTask(id) + } + + // 处理新增/更新的任务 + if len(upsertedIDs) > 0 { + var tasks []models.Task + database.DB.Where("id IN ?", upsertedIDs).Find(&tasks) + for _, t := range tasks { + if utils.DerefBool(t.Enabled, true) { + es.AddCronTask(&t) + } else { + es.RemoveCronTask(t.ID) + } + } + } +} + +// StopTaskExecution stops a running task execution by LogID +func (es *ExecutorService) StopTaskExecution(logID string) error { + var taskLog models.TaskLog + res := database.DB.Where("id = ?", logID).Limit(1).Find(&taskLog) + if res.Error != nil || res.RowsAffected == 0 { + return fmt.Errorf("停止失败:找不到指定的执行记录 (LogID: %s)", logID) + } + + // 1. 状态预校验 + if taskLog.Status != constant.TaskStatusRunning { + statusText := "已结束" + switch taskLog.Status { + case constant.TaskStatusSuccess: + statusText = "执行成功" + case constant.TaskStatusFailed: + statusText = "执行失败" + case constant.TaskStatusTimeout: + statusText = "执行超时" + case constant.TaskStatusCancelled: + statusText = "已取消" + } + return fmt.Errorf("操作无效:任务当前状态为 [%s],无需停止", statusText) + } + + task := es.taskService.GetTaskByID(taskLog.TaskID) + if task == nil { + return fmt.Errorf("停止失败:关联的任务信息已丢失") + } + + // 2. 远程任务逻辑 + if task.AgentID != nil && *task.AgentID != "" { + // 校验 Agent 是否在线 + if !es.agentWSManager.IsAgentOnline(*task.AgentID) { + return fmt.Errorf("停止失败:目标 Agent (%s) 当前离线,无法下发指令", *task.AgentID) + } + + logger.Infof("[Executor] 请求停止远程任务 #%s (Agent #%s, LogID: %s)", task.ID, *task.AgentID, logID) + err := es.agentWSManager.SendToAgent(*task.AgentID, constant.WSTypeStop, map[string]interface{}{ + "log_id": logID, + }) + if err != nil { + return fmt.Errorf("下发停止指令失败: %v", err) + } + return nil + } + + // 3. 本地任务逻辑 + logger.Infof("[Executor] 请求停止本地任务 #%s (LogID: %s)", task.ID, logID) + if es.scheduler.StopLog(logID) { + return nil + } + + // 4. 容错处理:如果调度器中没有句柄,但数据库状态还是 running + // 这通常发生在程序异常重启后,需要手动清理掉这个“僵尸状态” + taskLog.Status = constant.TaskStatusFailed + errorMessage := "任务执行实例已丢失(可能由于系统重启导致),已自动同步状态为失败" + taskLog.Error = models.BigText(errorMessage) + + // 更新数据库状态 + database.DB.Model(&taskLog).Updates(map[string]interface{}{ + "status": taskLog.Status, + "error": taskLog.Error, + }) + + return fmt.Errorf("停止失败:%s", errorMessage) +} + +// GetRunningCount 获取正在运行任务数量 +func (es *ExecutorService) GetRunningCount() int { + return es.scheduler.GetRunningTaskCount() +} + +// ExecuteCommand executes a shell command with default timeout +func (es *ExecutorService) ExecuteCommand(command string) *executor.ExecutionResult { + return es.ExecuteCommandWithTimeout(command, time.Duration(constant.DefaultTaskTimeout)*time.Minute) +} + +// ExecuteCommandWithTimeout executes a shell command with specified timeout +func (es *ExecutorService) ExecuteCommandWithTimeout(command string, timeout time.Duration) *executor.ExecutionResult { + return es.ExecuteCommandWithEnv(command, timeout, nil) +} + +// ExecuteCommandWithEnv executes a shell command with specified timeout and environment variables +func (es *ExecutorService) ExecuteCommandWithEnv(command string, timeout time.Duration, envVars []string) *executor.ExecutionResult { + return es.ExecuteCommandWithOptions(command, timeout, envVars, "") +} + +// ExecuteCommandWithOptions executes a shell command with specified timeout, environment variables and working directory +func (es *ExecutorService) ExecuteCommandWithOptions(command string, timeout time.Duration, envVars []string, workDir string) *executor.ExecutionResult { + req := &executor.ExecutionRequest{ + Command: command, + Timeout: int(timeout.Minutes()), + Envs: envVars, + WorkDir: workDir, + Type: executor.TaskTypeSystem, + } + + res, _ := es.scheduler.ExecuteSync(req) + + // 使用独立锁保存结果 + // TODO: 适配 ExecutionResult 的转换并保存结果 + + return res +} + +// UpdateResult 更新内存中的执行结果缓冲 +func (es *ExecutorService) UpdateResult(res executor.ExecutionResult) { + es.resultsMu.Lock() + defer es.resultsMu.Unlock() + + // 按照用户要求,任务结束后清空 Output 以节省内存。 + // 结束状态的任务如果需要查看完整日志,会自动从数据库/文件中读取。 + isFinished := res.Status == constant.TaskStatusSuccess || + res.Status == constant.TaskStatusFailed || + res.Status == constant.TaskStatusTimeout || + res.Status == constant.TaskStatusCancelled + + if isFinished { + res.Output = "" + } + + // 查找是否已存在(通过 LogID) + for i := range es.results { + if es.results[i].LogID == res.LogID && res.LogID != "" { + es.results[i] = res + return + } + } + + // 不存在则追加到末尾 + if len(es.results) >= 100 { + // 移除最旧的一个 + es.results = es.results[1:] + } + es.results = append(es.results, res) +} + +// GetLastResults returns the last execution results +func (es *ExecutorService) GetLastResults(count int) []executor.ExecutionResult { + es.resultsMu.RLock() + defer es.resultsMu.RUnlock() + + total := len(es.results) + if count > total { + count = total + } + + if count <= 0 { + return []executor.ExecutionResult{} + } + + // 返回副本,按时间倒序(最新的在前) + res := make([]executor.ExecutionResult, 0, count) + for i := 0; i < count; i++ { + res = append(res, es.results[total-1-i]) + } + return res +} + +// --- 以下内容从 TaskExecutionService 合并 --- + +// CleanupRunningTasks 清理所有任务的运行状态(在重启时调用) +func (es *ExecutorService) CleanupRunningTasks() error { + logger.Info("[Executor] 正在清理残留的任务运行状态...") + return database.DB.Model(&models.Task{}).Where("1=1").Update("running_go", "[]").Error +} + +// CheckConcurrency 检查任务并发限制(只读检查) +func (es *ExecutorService) CheckConcurrency(taskID string) error { + var task models.Task + res := database.DB.Select("config, running_go").Where("id = ?", taskID).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + if res.Error != nil { + return res.Error + } + return gorm.ErrRecordNotFound + } + var goids []int64 + if string(task.RunningGo) != "" { + _ = json.Unmarshal([]byte(string(task.RunningGo)), &goids) + } + + var config models.TaskConfig + if string(task.Config) != "" { + _ = json.Unmarshal([]byte(string(task.Config)), &config) + } + + if config.Concurrency == 0 && len(goids) > 0 { + // 检查目标 Agent 是否开启了排队机制 + var isAgentQueueing bool + if task.AgentID != nil && *task.AgentID != "" { + var agent models.Agent + if err := database.DB.Select("scheduler_config").Where("id = ?", *task.AgentID).First(&agent).Error; err == nil { + isAgentQueueing = agent.SchedulerConfig.StrictQueue + } + } + if !isAgentQueueing { + return fmt.Errorf("任务正在运行中,拒绝并行执行,请前往日志查看") + } + } + return nil +} + +// AddRunningGo 添加当前 goroutine ID 到任务的 running_go 字段 +func (es *ExecutorService) AddRunningGo(taskID string) (int64, error) { + goid := utils.GetGoroutineID() + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + lastErr = database.DB.Transaction(func(tx *gorm.DB) error { + var task models.Task + res := tx.Where("id = ?", taskID).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + if res.Error != nil { + return res.Error + } + return gorm.ErrRecordNotFound + } + var goids []int64 + if task.RunningGo != "" { + _ = json.Unmarshal([]byte(task.RunningGo), &goids) + } + + // 解析配置以获取并发设置 + var config models.TaskConfig + if task.Config != "" { + _ = json.Unmarshal([]byte(task.Config), &config) + } + + // 如果并发为0(禁用)且已有执行中的任务,返回错误 + if config.Concurrency == 0 && len(goids) > 0 { + // 检查目标 Agent 是否开启了排队机制 + var isAgentQueueing bool + if task.AgentID != nil && *task.AgentID != "" { + var agent models.Agent + if err := tx.Select("scheduler_config").Where("id = ?", *task.AgentID).First(&agent).Error; err == nil { + isAgentQueueing = agent.SchedulerConfig.StrictQueue + } + } + if !isAgentQueueing { + return fmt.Errorf("task is running") + } + } + + goids = append(goids, goid) + data, _ := json.Marshal(goids) + return tx.Model(&task).Update("running_go", models.BigText(data)).Error + }) + if lastErr == nil { + return goid, nil + } + // 如果是业务错误(任务正在运行),不重试 + if lastErr.Error() == "task is running" { + return goid, lastErr + } + // 数据库锁错误,等待后重试 + time.Sleep(100 * time.Millisecond) + } + return goid, fmt.Errorf("任务并发限制: %v", lastErr) +} + +// RemoveRunningGo 从任务的 running_go 字段移除指定 goroutine ID +func (es *ExecutorService) RemoveRunningGo(taskID string, goid int64) { + for attempt := 0; attempt < 3; attempt++ { + err := database.DB.Transaction(func(tx *gorm.DB) error { + var task models.Task + res := tx.Where("id = ?", taskID).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + if res.Error != nil { + return res.Error + } + return gorm.ErrRecordNotFound + } + var goids []int64 + if task.RunningGo != "" { + _ = json.Unmarshal([]byte(task.RunningGo), &goids) + } + newGoids := make([]int64, 0) + for _, id := range goids { + if id != goid { + newGoids = append(newGoids, id) + } + } + data, _ := json.Marshal(newGoids) + return tx.Model(&task).Update("running_go", string(data)).Error + }) + if err == nil { + return + } + time.Sleep(100 * time.Millisecond) + } +} + +// ExecuteRemoteForScheduler 供 Scheduler 调用,执行远程任务并等待结果 +func (es *ExecutorService) ExecuteRemoteForScheduler(ctx context.Context, task *models.Task, logID string, envs string, secrets []string) (*executor.Result, error) { + agentID := *task.AgentID + logger.Infof("[Executor] 远程执行任务 #%s: %s (Agent #%s, LogID: %s)", task.ID, task.Name, agentID, logID) + + // 1. 检查 Agent 状态 + var agent models.Agent + res := database.DB.Where("id = ?", agentID).Limit(1).Find(&agent) + if res.Error != nil || res.RowsAffected == 0 { + return nil, fmt.Errorf("Agent #%s 不存在", agentID) + } + if !utils.DerefBool(agent.Enabled, true) { + return nil, fmt.Errorf("Agent #%s 已禁用", agentID) + } + if es.agentWSManager == nil { + return nil, fmt.Errorf("AgentWSManager 未初始化") + } + + // 2. 注册结果等待者 + resultChan := es.agentWSManager.RegisterRemoteWaiter(logID) + defer es.agentWSManager.UnregisterRemoteWaiter(logID) + + // 3. 发送指令 + err := es.agentWSManager.SendToAgent(agentID, constant.WSTypeExecute, map[string]interface{}{ + "task_id": task.ID, + "log_id": logID, + "envs": envs, + "secrets": secrets, + "command": task.Command, + "pre_command": task.PreCommand, + "post_command": task.PostCommand, + }) + if err != nil { + return nil, fmt.Errorf("发送执行命令失败: %v", err) + } + + // 4. 等待结果或超时 + timeout := task.Timeout + + start := time.Now() + + var timeoutChan <-chan time.Time + if timeout > 0 { + timeoutChan = time.After(time.Duration(timeout) * time.Minute) + } + + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + select { + case agentResult := <-resultChan: + return &executor.Result{ + Output: agentResult.Output, + Error: agentResult.Error, + Status: agentResult.Status, + Duration: agentResult.Duration, + ExitCode: agentResult.ExitCode, + StartTime: time.Unix(agentResult.StartTime, 0), + EndTime: time.Unix(agentResult.EndTime, 0), + }, nil + + case <-timeoutChan: + end := time.Now() + return &executor.Result{ + Status: constant.TaskStatusFailed, + Error: "等待 Agent 结果超时", + Duration: end.Sub(start).Milliseconds(), + ExitCode: -1, + StartTime: start, + EndTime: end, + }, fmt.Errorf("等待 Agent 结果超时") + + case <-ctx.Done(): + // 收到取消信号,向 Agent 发送停止指令 + _ = es.agentWSManager.SendToAgent(agentID, constant.WSTypeStop, map[string]interface{}{ + "log_id": logID, + }) + end := time.Now() + return &executor.Result{ + Status: constant.TaskStatusCancelled, + Error: "任务被手动停止或删除", + Duration: end.Sub(start).Milliseconds(), + ExitCode: -1, + StartTime: start, + EndTime: end, + }, fmt.Errorf("任务被主动停止") + + case <-ticker.C: + // 定期检查 Agent 是否在线 + if !es.agentWSManager.IsAgentOnline(agentID) { + end := time.Now() + return &executor.Result{ + Status: constant.TaskStatusFailed, + Error: "Agent 离线,任务被迫终止", + Duration: end.Sub(start).Milliseconds(), + ExitCode: -1, + StartTime: start, + EndTime: end, + }, fmt.Errorf("Agent 离线") + } + } + } +} + +// HandleAgentResult 处理来自 Agent 的异步结果 +func (es *ExecutorService) HandleAgentResult(result *models.AgentTaskResult) error { + // 加载机密以进行脱敏处理 + var secrets []string + task := es.taskService.GetTaskByID(result.TaskID) + if task != nil { + _, secrets = es.loadEnvVars(task.ID, string(task.Envs)) + + // 如果是仓库同步任务,补充 AuthToken + if task.Type == constant.TaskTypeRepo { + var repoCfg models.RepoConfig + if err := json.Unmarshal([]byte(task.Config), &repoCfg); err == nil && repoCfg.AuthToken != "" { + secrets = append(secrets, repoCfg.AuthToken) + } + } + } + + masks := append([]string{}, secrets...) + masks = append(masks, utils.GetSystemSecrets()...) + result.Command = utils.MaskSecrets(result.Command, masks) + + taskLog, err := es.taskLogService.CreateTaskLogFromAgentResult(result) + if err != nil { + return err + } + + err = es.taskLogService.ProcessTaskCompletion(taskLog) + if err != nil { + return err + } + + // 处理重试逻辑(针对 Agent 自主触发的定时任务) + if task != nil { + isSuccess := result.Status == constant.TaskStatusSuccess + es.HandleTaskRetry(task, &executor.ExecutionRequest{ + TaskID: task.ID, + Type: executor.TaskTypeCron, + Metadata: executor.ExecutionMetadata{ + RetryIndex: 0, // 初始上报视为第 0 次 + }, + }, isSuccess, result.Status, result.ExitCode) + } + + return nil +} + +// BuildRepoCommand 构建仓库同步任务的命令 +func (es *ExecutorService) BuildRepoCommand(task *models.Task) (string, string) { + return BuildRepoCommand(task) +} + +// BuildRepoCommand 构建仓库同步任务的命令(独立函数,方便 AgentService 调用) +func BuildRepoCommand(task *models.Task) (string, string) { + var config models.RepoConfig + if err := json.Unmarshal([]byte(task.Config), &config); err != nil { + return "", "" + } + + targetPath := config.TargetPath + if targetPath == "" { + targetPath = constant.ScriptsWorkDir + } else if !filepath.IsAbs(targetPath) { + targetPath = filepath.Join(constant.ScriptsWorkDir, targetPath) + } + absTargetPath, _ := filepath.Abs(targetPath) + + exePath, err := os.Executable() + if err != nil { + exePath = "taskpool" // Fallback if executable path can't be found + } + + // 尽量使用代号 $SCRIPTS_DIR$ 替代绝对路径,增加可读性和可移植性 + scriptsDir, _ := filepath.Abs(constant.ScriptsWorkDir) + displayTargetPath := absTargetPath + if rel, err := filepath.Rel(scriptsDir, absTargetPath); err == nil && !strings.HasPrefix(rel, "..") { + if rel == "." { + displayTargetPath = constant.ScriptsDirPlaceholder + } else { + displayTargetPath = constant.ScriptsDirPlaceholder + "/" + filepath.ToSlash(rel) + } + } + + args := []string{ + "reposync", + "--source-type", config.SourceType, + "--source-url", config.SourceURL, + "--target-path", displayTargetPath, + } + if config.Branch != "" { + args = append(args, "--branch", config.Branch) + } + if config.SparsePath != "" { + args = append(args, "--path", config.SparsePath) + } + if config.SingleFile { + args = append(args, "--single-file") + } + if config.Proxy != "" && config.Proxy != "none" { + args = append(args, "--proxy", config.Proxy) + if config.Proxy == "custom" && config.ProxyURL != "" { + args = append(args, "--proxy-url", config.ProxyURL) + } + } + if config.AuthToken != "" { + args = append(args, "--auth-token", config.AuthToken) + } + if config.WhitelistPaths != "" { + args = append(args, "--whitelist-paths", config.WhitelistPaths) + } + if config.Blacklist != "" { + args = append(args, "--blacklist", config.Blacklist) + } + if config.Dependence != "" { + args = append(args, "--dependence", config.Dependence) + } + if config.CommentToTask == "true" { + args = append(args, "--commenttotask", "true") + } + if config.Extensions != "" { + args = append(args, "--extensions", config.Extensions) + } + if config.RepoDirName != "" { + args = append(args, "--repo-name", config.RepoDirName) + } + if string(task.PreCommand) != "" { + args = append(args, "--pre-command", string(task.PreCommand)) + } + if string(task.PostCommand) != "" { + args = append(args, "--post-command", string(task.PostCommand)) + } + + // 传递任务 ID,以便 reposync 内部直接处理脚本注册并输出日志 + args = append(args, "--task-id", task.ID) + args = append(args, "--task-timeout", fmt.Sprintf("%d", task.Timeout)) + if langData, err := json.Marshal(task.Languages); err == nil { + args = append(args, "--task-langs", string(langData)) + } + + // 为了防止 shell 解释特殊字符(如 |),对每个参数进行转义/加引号 + quotedArgs := make([]string, len(args)) + for i, arg := range args { + quotedArgs[i] = utils.QuotePath(arg) + } + + cmdStr := utils.QuotePath(exePath) + " " + strings.Join(quotedArgs, " ") + return buildRepoCommandEnvPrefix() + cmdStr, filepath.Dir(exePath) +} + +// loadEnvVars 加载环境变量和掩码信息,支持全局注入及重名合并 +func (es *ExecutorService) loadEnvVars(taskID string, envIDs string) ([]string, []string) { + // 1. 检查是否开启了注入全部环境变量 + if taskID != "" && es.taskService != nil { + task := es.taskService.GetTaskByID(taskID) + if task != nil && task.Config != "" { + var config models.TaskConfig + if err := json.Unmarshal([]byte(task.Config), &config); err == nil { + if config.AllEnvs { + if es.envService != nil { + return es.envService.GetAllEnvVarsAndSecrets() + } + } + } + } + } + + // 2. 否则按 ID 列表进行加载(支持合并逻辑在 envService 中处理) + if envIDs == "" { + return nil, nil + } + + if es.envService != nil { + return es.envService.GetEnvVarsAndSecretsByIDs(envIDs) + } + + return nil, nil +} + +// refreshExecutionRequestEnvs 重新加载最新的环境变量,并与原请求中的变量合并(保留额外变量) +func (es *ExecutorService) refreshExecutionRequestEnvs(req *executor.ExecutionRequest, task *models.Task) { + if task == nil || (req.Type != executor.TaskTypeCron && req.Type != executor.TaskTypeManual) { + return + } + + // 1. 备份原请求中的环境变量(用于后续保留手动指定的额外变量) + currentEnvs := req.Envs + + // 2. 从数据库加载最新的环境变量设置 + envs, secrets := es.loadEnvVars(task.ID, string(task.Envs)) + req.Envs = envs + req.Secrets = secrets + + // 3. 将原请求中存在但数据库中不存在的“额外变量”合并回来(如 API 注入、手动执行参数等) + for _, ce := range currentEnvs { + idx := strings.Index(ce, "=") + if idx == -1 { + continue + } + name := ce[:idx] + found := false + for _, ne := range envs { + if strings.HasPrefix(ne, name+"=") { + found = true + break + } + } + if !found { + req.Envs = append(req.Envs, ce) + } + } +} + +func (es *ExecutorService) ResolvePath(path string) string { + absScriptsDir := resolveAbsScriptsDir() + return strings.ReplaceAll(path, constant.ScriptsDirPlaceholder, absScriptsDir) +} + +func buildRepoCommandEnvPrefix() string { + return utils.BuildShellEnvPrefix(utils.BuildRuntimeProcessEnv()) +} + +func resolveAbsScriptsDir() string { + return utils.ResolveAbsScriptsDir() +} diff --git a/internal/services/tasks/task_log_service.go b/internal/services/tasks/task_log_service.go new file mode 100644 index 0000000..f452c15 --- /dev/null +++ b/internal/services/tasks/task_log_service.go @@ -0,0 +1,251 @@ +package tasks + +import ( + "encoding/json" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/systime" + "github.com/engigu/taskpool/internal/utils" +) + +// SendStatsService 接口定义(避免循环依赖) +type SendStatsService interface { + IncrementStats(taskID string, status string) error +} + +// TaskLogService 任务日志服务 +type TaskLogService struct { + sendStatsService SendStatsService +} + +// NewTaskLogService 创建任务日志服务 +func NewTaskLogService(sendStatsService SendStatsService) *TaskLogService { + return &TaskLogService{ + sendStatsService: sendStatsService, + } +} + +// CleanConfig 清理配置 +type CleanConfig struct { + Type string `json:"type"` // day 或 count + Keep int `json:"keep"` // 保留天数或条数 +} + +// CreateEmptyLog 创建一个空的日志记录(任务开始时调用) +func (s *TaskLogService) CreateEmptyLog(taskID string, command string) (*models.TaskLog, error) { + startTime := models.Now() + taskLog := &models.TaskLog{ + ID: utils.GenerateID(), + TaskID: taskID, + Command: models.BigText(command), + Status: "running", + StartTime: &startTime, + CreatedAt: models.Now(), + } + if err := database.DB.Create(taskLog).Error; err != nil { + return nil, err + } + + // 任务开始时即更新任务的 last_run 为启动时间 + database.DB.Model(&models.Task{}).Where("id = ?", taskID).Update("last_run", startTime) + + return taskLog, nil +} + +// SaveTaskLog 保存或更新任务日志 +func (s *TaskLogService) SaveTaskLog(taskLog *models.TaskLog) error { + var err error + if taskLog.ID != "" { + // 先检查记录是否存在,如果不存在则创建,存在则更新 + var count int64 + database.DB.Model(&models.TaskLog{}).Where("id = ?", taskLog.ID).Count(&count) + if count > 0 { + err = database.DB.Model(taskLog).Where("id = ?", taskLog.ID).Updates(taskLog).Error + } else { + err = database.DB.Create(taskLog).Error + } + } else { + taskLog.ID = utils.GenerateID() + if taskLog.CreatedAt.Time().IsZero() { + taskLog.CreatedAt = models.Now() + } + err = database.DB.Create(taskLog).Error + } + + if err != nil { + return err + } + + // 更新任务的 last_run + // 更新任务的 last_run,优先使用日志记录的启动时间 + lastRun := models.Now() + if taskLog.StartTime != nil { + lastRun = *taskLog.StartTime + } + database.DB.Model(&models.Task{}).Where("id = ?", taskLog.TaskID).Update("last_run", lastRun) + + return nil +} + +// UpdateTaskDuration 更新任务耗时(心跳) +func (s *TaskLogService) UpdateTaskDuration(logID string, duration int64) error { + return database.DB.Model(&models.TaskLog{}).Where("id = ?", logID).Update("duration", duration).Error +} + +// UpdateLogCommand 更新日志中的命令内容(用于动态生成的命令脱敏) +func (s *TaskLogService) UpdateLogCommand(logID string, command string) error { + return database.DB.Model(&models.TaskLog{}).Where("id = ?", logID).Update("command", models.BigText(command)).Error +} + +// UpdateTaskStats 更新任务统计 +func (s *TaskLogService) UpdateTaskStats(taskID string, status string) { + if s.sendStatsService == nil { + logger.Error("[TaskLog] SendStatsService 未初始化") + return + } + err := s.sendStatsService.IncrementStats(taskID, status) + if err != nil { + logger.Errorf("UpdateTaskStats err: %v", err) + return + } +} + +// CleanTaskLogs 清理任务日志 +func (s *TaskLogService) CleanTaskLogs(taskID string) { + var task models.Task + res := database.DB.Where("id = ?", taskID).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + return + } + + if task.CleanConfig == "" { + return + } + + var config CleanConfig + if err := json.Unmarshal([]byte(task.CleanConfig), &config); err != nil { + logger.Errorf("[TaskLog] 解析清理配置失败: %v", err) + return + } + + if config.Keep <= 0 { + return + } + + var deleted int64 + switch config.Type { + case "day": + cutoff := systime.InCST(time.Now()).AddDate(0, 0, -config.Keep) + result := database.DB.Where("task_id = ? AND created_at < ?", taskID, cutoff).Delete(&models.TaskLog{}) + deleted = result.RowsAffected + case "count": + var boundaryLog models.TaskLog + res := database.DB.Where("task_id = ?", taskID).Order("id DESC").Offset(config.Keep - 1).Limit(1).Find(&boundaryLog) + if res.Error == nil && res.RowsAffected > 0 { + result := database.DB.Where("task_id = ? AND id < ?", taskID, boundaryLog.ID).Delete(&models.TaskLog{}) + deleted = result.RowsAffected + } + } + + if deleted > 0 { + logger.Infof("[TaskLog] 清理旧日志: #%s 共 %d 条", taskID, deleted) + } +} + +// ProcessTaskCompletion 处理任务完成后的所有操作(保存日志、更新统计、清理旧日志) +func (s *TaskLogService) ProcessTaskCompletion(taskLog *models.TaskLog) error { + // 1. 保存/更新日志 + if err := s.SaveTaskLog(taskLog); err != nil { + return err + } + + // 2. 更新统计 + s.UpdateTaskStats(taskLog.TaskID, taskLog.Status) + + // 3. 异步清理旧日志 + go s.CleanTaskLogs(taskLog.TaskID) + + return nil +} + +// CreateTaskLogFromAgentResult 从 Agent 结果创建任务日志 +func (s *TaskLogService) CreateTaskLogFromAgentResult(result *models.AgentTaskResult) (*models.TaskLog, error) { + // 裁剪并压缩输出 + trimmedOutput := utils.TrimLog(result.Output, constant.MaxLogSize) + compressed, err := utils.CompressToBase64(trimmedOutput) + if err != nil { + logger.Errorf("[TaskLog] 压缩日志失败: %v", err) + compressed = "" + } + + logID := result.LogID + if logID == "" { + logID = utils.GenerateID() + } + + taskLog := &models.TaskLog{ + ID: logID, + TaskID: result.TaskID, + AgentID: &result.AgentID, + Command: models.BigText(result.Command), + Output: models.BigText(compressed), + Error: models.BigText(result.Error), + Status: result.Status, + Duration: result.Duration, + ExitCode: result.ExitCode, + CreatedAt: models.Now(), + } + + // 处理开始和结束时间 + if result.StartTime > 0 { + startTime := models.LocalTime(time.Unix(result.StartTime, 0)) + taskLog.StartTime = &startTime + } + if result.EndTime > 0 { + endTime := models.LocalTime(time.Unix(result.EndTime, 0)) + taskLog.EndTime = &endTime + } + + return taskLog, nil +} + +// CreateTaskLogFromLocalExecution 从本地执行结果创建任务日志 +func (s *TaskLogService) CreateTaskLogFromLocalExecution(taskID string, command, output, systemErr, status string, duration int64, exitCode int, start, end time.Time, isCompressed bool) (*models.TaskLog, error) { + var compressed string + var err error + + if isCompressed { + compressed = output + } else { + // 裁剪并压缩输出 + trimmedOutput := utils.TrimLog(output, constant.MaxLogSize) + compressed, err = utils.CompressToBase64(trimmedOutput) + if err != nil { + logger.Errorf("[TaskLog] 压缩日志失败: %v", err) + compressed = "" + } + } + + startTime := models.LocalTime(start) + endTime := models.LocalTime(end) + + taskLog := &models.TaskLog{ + ID: utils.GenerateID(), + TaskID: taskID, + Command: models.BigText(command), + Output: models.BigText(compressed), + Error: models.BigText(systemErr), + Status: status, + Duration: duration, + ExitCode: exitCode, + StartTime: &startTime, + EndTime: &endTime, + CreatedAt: models.Now(), + } + + return taskLog, nil +} diff --git a/internal/services/tasks/task_service.go b/internal/services/tasks/task_service.go new file mode 100644 index 0000000..d04080c --- /dev/null +++ b/internal/services/tasks/task_service.go @@ -0,0 +1,287 @@ +package tasks + +import ( + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services/relation" + "github.com/engigu/taskpool/internal/utils" +) + +// TaskParam 任务创建与更新参数传输对象 +type TaskParam struct { + Name string + Remark string + Command string + PreCommand string + PostCommand string + Tags string + Type string + Config string + Schedule string + Timeout int + WorkDir string + CleanConfig string + Envs string + Languages models.TaskLanguages + AgentID *string + TriggerType string + RetryCount int + RetryInterval int + RandomRange int + SourceID string + PinType string + Enabled bool +} + +type TaskService struct { +} + +func NewTaskService() *TaskService { + return &TaskService{} +} + +func (ts *TaskService) GetTaskBySourceID(sourceID string) *models.Task { + var task models.Task + res := database.DB.Where("source_id = ?", sourceID).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + ts.loadTagsAndEnvs([]models.Task{task}) + + return &task +} + +func (ts *TaskService) CreateTask(p *TaskParam) *models.Task { + if p.Type == "" { + p.Type = "task" + } + if p.TriggerType == "" { + p.TriggerType = constant.TriggerTypeCron + } + if p.PinType == "" { + p.PinType = constant.PinTypeNone + } + task := &models.Task{ + ID: utils.GenerateID(), + Name: p.Name, + Remark: p.Remark, + Command: models.BigText(p.Command), + PreCommand: models.BigText(p.PreCommand), + PostCommand: models.BigText(p.PostCommand), + PinType: p.PinType, + Tags: p.Tags, + Type: p.Type, + TriggerType: p.TriggerType, + Config: models.BigText(p.Config), + Schedule: p.Schedule, + Timeout: p.Timeout, + WorkDir: p.WorkDir, + CleanConfig: p.CleanConfig, + Envs: models.BigText(p.Envs), + Languages: p.Languages, + AgentID: p.AgentID, + Enabled: utils.BoolPtr(true), + RetryCount: p.RetryCount, + RetryInterval: p.RetryInterval, + RandomRange: p.RandomRange, + SourceID: p.SourceID, + CreatedAt: models.Now(), + UpdatedAt: models.Now(), + } + if p.TriggerType != constant.TriggerTypeCron { + task.NextRun = nil + } + database.DB.Select("*").Create(task) + relation.DataRelation.SaveTags(task.ID, constant.RelationTypeTaskTag, p.Tags) + task.Tags = p.Tags + relation.DataRelation.SaveRelations(task.ID, constant.RelationTypeTaskEnv, p.Envs) + task.Envs = models.BigText(p.Envs) + + return task +} + +func (ts *TaskService) GetTasks() []models.Task { + var tasks []models.Task + database.DB.Find(&tasks) + ts.loadTagsAndEnvs(tasks) + return tasks +} + +// GetTasksWithPagination 分页获取任务列表 +func (ts *TaskService) GetTasksWithPagination(page, pageSize int, name string, agentID *string, tags string, taskType string, sortBy string, order string) ([]models.Task, int64) { + var tasks []models.Task + var total int64 + + query := database.DB.Model(&models.Task{}) + if name != "" { + query = query.Where("name LIKE ? OR remark LIKE ?", "%"+name+"%", "%"+name+"%") + } + + // 标签筛选 (交集或并集均可,这里保留原本的逻辑为并集,但是利用数据关联表) + if tags != "" { + tagList := strings.Split(tags, ",") + var validTags []string + for _, tag := range tagList { + tag = strings.TrimSpace(tag) + if tag != "" { + validTags = append(validTags, tag) + } + } + if len(validTags) > 0 { + var storageIDs []string + database.DB.Model(&models.DataStorage{}).Where("type = ? AND name IN ?", constant.RelationTypeTaskTag, validTags).Pluck("id", &storageIDs) + + var taskIDs []string + if len(storageIDs) > 0 { + database.DB.Model(&models.DataRelation{}).Where("type = ? AND relate_id IN ?", constant.RelationTypeTaskTag, storageIDs).Pluck("data_id", &taskIDs) + } + + if len(taskIDs) > 0 { + query = query.Where("id IN ?", taskIDs) + } else { + query = query.Where("1 = 0") + } + } + } + + if taskType != "" && taskType != "all" { + query = query.Where("type = ?", taskType) + } + if agentID != nil { + query = query.Where("agent_id = ?", *agentID) + } + + sortColumn := "created_at" + if sortBy != "" { + switch sortBy { + case "name", "next_run", "last_run", "created_at", "enabled": + sortColumn = sortBy + } + } + sortOrder := "DESC" + if strings.ToUpper(order) == "ASC" { + sortOrder = "ASC" + } + + query.Count(&total) + query.Order("pin_type DESC, " + sortColumn + " " + sortOrder).Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks) + ts.loadTagsAndEnvs(tasks) + + return tasks, total +} + +func (ts *TaskService) GetTaskByID(id string) *models.Task { + var task models.Task + res := database.DB.Where("id = ?", id).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + tasks := []models.Task{task} + ts.loadTagsAndEnvs(tasks) + + return &tasks[0] +} + +func (ts *TaskService) UpdateTask(id string, p *TaskParam) *models.Task { + var task models.Task + res := database.DB.Where("id = ?", id).Limit(1).Find(&task) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + task.Name = p.Name + task.Remark = p.Remark + task.Command = models.BigText(p.Command) + task.PreCommand = models.BigText(p.PreCommand) + task.PostCommand = models.BigText(p.PostCommand) + task.PinType = p.PinType + task.Schedule = p.Schedule + task.Timeout = p.Timeout + task.WorkDir = p.WorkDir + task.CleanConfig = p.CleanConfig + task.Enabled = &p.Enabled + task.AgentID = p.AgentID + task.Languages = p.Languages + task.Config = models.BigText(p.Config) + task.RetryCount = p.RetryCount + task.RetryInterval = p.RetryInterval + task.RandomRange = p.RandomRange + if p.Type != "" { + task.Type = p.Type + } + if p.TriggerType != "" { + task.TriggerType = p.TriggerType + } + if p.SourceID != "" { + task.SourceID = p.SourceID + } + + database.DB.Model(&task).Select( + "Name", "Remark", "Command", "Tags", "Schedule", "Timeout", "WorkDir", + "CleanConfig", "Enabled", "AgentID", "Languages", + "RetryCount", "RetryInterval", "RandomRange", "Type", + "TriggerType", "Config", "SourceID", "PinType", + "PreCommand", "PostCommand", + ).Updates(&task) + + relation.DataRelation.SaveTags(task.ID, constant.RelationTypeTaskTag, p.Tags) + task.Tags = p.Tags + relation.DataRelation.SaveRelations(task.ID, constant.RelationTypeTaskEnv, p.Envs) + task.Envs = models.BigText(p.Envs) + + return &task +} + +func (ts *TaskService) DeleteTask(id string) bool { + // 同时删除关联的通知推送设置 + database.DB.Where("type = ? AND data_id = ?", constant.BindingTypeTask, id).Delete(&models.NotifyBinding{}) + relation.DataRelation.CleanRelations(id, constant.RelationTypeTaskTag) + relation.DataRelation.CleanRelations(id, constant.RelationTypeTaskEnv) + + result := database.DB.Where("id = ?", id).Delete(&models.Task{}) + return result.RowsAffected > 0 +} + +func (ts *TaskService) BatchDeleteTasks(ids []string) int64 { + // 同时删除关联的通知推送设置 + database.DB.Where("type = ? AND data_id IN ?", constant.BindingTypeTask, ids).Delete(&models.NotifyBinding{}) + database.DB.Where("type = ? AND data_id IN ?", constant.RelationTypeTaskTag, ids).Delete(&models.DataRelation{}) + database.DB.Where("type = ? AND data_id IN ?", constant.RelationTypeTaskEnv, ids).Delete(&models.DataRelation{}) + + result := database.DB.Where("id IN ?", ids).Delete(&models.Task{}) + return result.RowsAffected +} + +// GetAllTags 获取所有任务标签 +func (ts *TaskService) GetAllTags() ([]string, error) { + return relation.DataRelation.GetAllTags(constant.RelationTypeTaskTag) +} + +func (ts *TaskService) loadTagsAndEnvs(tasks []models.Task) { + if len(tasks) == 0 { + return + } + taskIDs := make([]string, len(tasks)) + for i, t := range tasks { + taskIDs[i] = t.ID + } + + tagsMap := relation.DataRelation.LoadTags(taskIDs, constant.RelationTypeTaskTag) + envsMap := relation.DataRelation.LoadRelations(taskIDs, constant.RelationTypeTaskEnv) + + for i, t := range tasks { + if tags, ok := tagsMap[t.ID]; ok { + tasks[i].Tags = strings.Join(tags, ",") + } else { + tasks[i].Tags = "" + } + + if envs, ok := envsMap[t.ID]; ok { + tasks[i].Envs = models.BigText(strings.Join(envs, ",")) + } else { + tasks[i].Envs = models.BigText("") + } + } +} diff --git a/internal/services/tasks/tiny_log.go b/internal/services/tasks/tiny_log.go new file mode 100644 index 0000000..b72e6fc --- /dev/null +++ b/internal/services/tasks/tiny_log.go @@ -0,0 +1,386 @@ +package tasks + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "unicode/utf8" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/utils" +) + +const ( + // maxLogBufferLen 定义了没有换行符时的最大缓冲长度 (4KB) + maxLogBufferLen = 4096 +) + +var ( + // globalTinyLogManager 跟踪所有活跃的 TinyLog 实例 + globalTinyLogManager = &TinyLogManager{ + logs: make(map[string]*TinyLog), + } +) + +type TinyLogManager struct { + mu sync.RWMutex + logs map[string]*TinyLog +} + +func (m *TinyLogManager) Register(log *TinyLog) { + m.mu.Lock() + defer m.mu.Unlock() + m.logs[log.LogID] = log +} + +func (m *TinyLogManager) Unregister(logID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.logs, logID) +} + +func (m *TinyLogManager) Get(logID string) *TinyLog { + m.mu.RLock() + defer m.mu.RUnlock() + return m.logs[logID] +} + +// GetActiveLog 通过 ID 获取活跃的 TinyLog 实例 +func GetActiveLog(logID string) *TinyLog { + return globalTinyLogManager.Get(logID) +} + +// TinyLog 是一个高性能、低内存占用的日志收集器 +type TinyLog struct { + LogID string + mu sync.RWMutex + file *os.File + path string + writer *bufio.Writer + subscribers []chan []byte + remainder []byte // Leftover bytes from previous write (partial lines) + masks []string // Secrets to mask + closed bool +} + +// NewTinyLog 创建一个新的 TinyLog 实例(基于临时文件存储)并注册它,支持将配置的 masks 替换为 ******** +func NewTinyLog(logID string, masks []string) (*TinyLog, error) { + f, err := os.CreateTemp("", "task_log_*.log") + if err != nil { + return nil, err + } + + tl := &TinyLog{ + LogID: logID, + file: f, + path: f.Name(), + writer: bufio.NewWriter(f), + subscribers: make([]chan []byte, 0), + masks: masks, + } + globalTinyLogManager.Register(tl) + return tl, nil +} + +// Write 实现 io.Writer 接口 +func (l *TinyLog) Write(p []byte) (n int, err error) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.closed { + return 0, os.ErrClosed + } + + originalInputLen := len(p) + var payload []byte + if len(l.remainder) > 0 { + // 为了防止 p 和 l.remainder 底层数组有重叠或不可预期的修改,这里分配新内存 + payload = make([]byte, len(l.remainder)+len(p)) + copy(payload, l.remainder) + copy(payload[len(l.remainder):], p) + l.remainder = nil + } else { + payload = p + } + + // 1. 寻找最后一个换行符 (\n 或 \r) + lastLineBreak := bytes.LastIndexAny(payload, "\n\r") + var completeBytes []byte + var remainder []byte + + if lastLineBreak != -1 { + // 2. 提取出完整的行 + completeBytes = payload[:lastLineBreak+1] + remainder = payload[lastLineBreak+1:] + } else { + // 3. 没有换行符,且如果长度超过最大缓冲,强制截断并输出,防止内存无限制增长 + if len(payload) > maxLogBufferLen { + // 寻找最后一个完整的 UTF-8 字符边界,避免乱码 + lastSafe := maxLogBufferLen + for i := maxLogBufferLen; i > 0 && i > maxLogBufferLen-4; i-- { + if utf8.RuneStart(payload[i-1]) { + if !utf8.FullRune(payload[i-1 : maxLogBufferLen]) { + lastSafe = i - 1 + } + break + } + } + completeBytes = payload[:lastSafe] + remainder = payload[lastSafe:] + } else { + // 保留当前所有内容到下一轮 (必须 copy,因为 payload 底层可能是 io.Copy 的复用 buf) + l.remainder = make([]byte, len(payload)) + copy(l.remainder, payload) + return originalInputLen, nil + } + } + + // 4. 将剩余部分保存 (必须 copy,防止后续 Read 覆盖底层数组) + if len(remainder) > 0 { + l.remainder = make([]byte, len(remainder)) + copy(l.remainder, remainder) + } else { + l.remainder = nil + } + + // 5. 将完整行转换为 UTF-8 并脱敏 + text := utils.MaskSecrets(utils.ToUTF8(completeBytes), l.masks) + outData := []byte(text) + + // 6. 输出安全部分 + _, err = l.writer.Write(outData) + if err != nil { + return 0, err + } + + // 6. 广播给所有订阅者 + if len(l.subscribers) > 0 { + for _, ch := range l.subscribers { + select { + case ch <- outData: + default: + // 如果订阅者处理太慢,丢弃消息以避免阻塞写入 + } + } + } + + return originalInputLen, nil +} + +// WriteString 方便地写入字符串 +func (l *TinyLog) WriteString(s string) (n int, err error) { + return l.Write([]byte(s)) +} + +// Subscribe 返回一个实时接收日志块的通道 +func (l *TinyLog) Subscribe() chan []byte { + l.mu.Lock() + defer l.mu.Unlock() + + ch := make(chan []byte, 100) // Buffer to handle bursts + l.subscribers = append(l.subscribers, ch) + return ch +} + +// Unsubscribe 移除订阅者 +func (l *TinyLog) Unsubscribe(ch chan []byte) { + l.mu.Lock() + defer l.mu.Unlock() + + for i, sub := range l.subscribers { + if sub == ch { + l.subscribers = append(l.subscribers[:i], l.subscribers[i+1:]...) + close(ch) + break + } + } +} + +// Close 完成写入,关闭文件并注销实例 +func (l *TinyLog) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + + if l.closed { + return nil + } + + // 处理剩余的字节 + if len(l.remainder) > 0 { + text := utils.MaskSecrets(utils.ToUTF8(l.remainder), l.masks) + data := []byte(text) + _, _ = l.writer.Write(data) + + // 通知订阅者最后一部分内容 + for _, ch := range l.subscribers { + select { + case ch <- data: + default: + } + } + l.remainder = nil + } + + // 将缓冲区刷新到文件 + if err := l.writer.Flush(); err != nil { + return err + } + + // 关闭所有订阅者通道 + for _, ch := range l.subscribers { + close(ch) + } + l.subscribers = nil + + l.closed = true + globalTinyLogManager.Unregister(l.LogID) + return l.file.Close() +} + +// CompressAndCleanup 读取临时文件,进行压缩处理,返回结果并删除临时文件 +func (l *TinyLog) CompressAndCleanup() (string, error) { + // Ensure closed + if !l.closed { + l.Close() + } + + // 打开临时文件进行读取 + f, err := os.Open(l.path) + if err != nil { + return "", err + } + defer func() { + f.Close() + os.Remove(l.path) // Cleanup + }() + + // 获取文件大小 + stat, err := f.Stat() + if err != nil { + return "", err + } + size := stat.Size() + + // 如果日志极短,免去压缩和 Base64 编码,直接以 raw: 明文形式返回 + if size <= int64(utils.MinCompressSize) { + content, err := io.ReadAll(f) + if err != nil { + return "", err + } + return "raw:" + string(content), nil + } + + // 创建压缩输出缓冲区 + var buf bytes.Buffer + b64Writer := base64.NewEncoder(base64.StdEncoding, &buf) + + // 使用 Pool 优化压缩 + zw := utils.GetZstdWriter(b64Writer) + defer utils.PutZstdWriter(zw) + + maxSize := int64(constant.MaxLogSize) + if maxSize < 1024*1024 { + maxSize = 1024 * 1024 + } + + var readStart int64 = 0 + if size > maxSize { + readStart = size - maxSize + // 写入一条截断提示 + truncatedMsg := fmt.Sprintf("\n\n[System] 日志过长,已自动截断,仅保留末尾 %d MB...\n\n", maxSize/1024/1024) + if _, err := zw.Write([]byte(truncatedMsg)); err != nil { + return "", err + } + } + + if readStart > 0 { + if _, err := f.Seek(readStart, io.SeekStart); err != nil { + return "", err + } + } + + // 流处理: 文件 -> Zstd -> Base64 -> 缓冲区 + if _, err := io.Copy(zw, f); err != nil { + return "", err + } + + // 关闭写入器以刷新数据 + if err := zw.Close(); err != nil { + return "", err + } + if err := b64Writer.Close(); err != nil { + return "", err + } + + return "zstd:" + buf.String(), nil +} + +// ReadLastLines 返回日志的最后 n 行 +func (l *TinyLog) ReadLastLines(n int) ([]byte, error) { + l.mu.RLock() + defer l.mu.RUnlock() + + // 刷新写入器以确保磁盘上的文件是最新的 + _ = l.writer.Flush() + + stat, err := os.Stat(l.path) + if err != nil { + return nil, err + } + + size := stat.Size() + var limit int64 = 65536 // 预览限制:最大 64KB + if size < limit { + limit = size + } + offset := size - limit + + data := make([]byte, limit) + f, err := os.Open(l.path) + if err != nil { + return nil, err + } + defer f.Close() + + _, err = f.ReadAt(data, offset) + if err != nil && err != io.EOF { + return nil, err + } + + lines := bytes.Split(data, []byte{'\n'}) + if len(lines) > n+1 { + return bytes.Join(lines[len(lines)-n-1:], []byte{'\n'}), nil + } + return data, nil +} + +// GetPath 返回临时文件路径 +func (l *TinyLog) GetPath() string { + return l.path +} + +// CleanupOrphanedTinyLogs 启动时清理残留的临时日志文件 +func CleanupOrphanedTinyLogs() { + tmpDir := os.TempDir() + files, err := os.ReadDir(tmpDir) + if err != nil { + return + } + + count := 0 + for _, file := range files { + if !file.IsDir() && len(file.Name()) > 9 && file.Name()[:9] == "task_log_" && filepath.Ext(file.Name()) == ".log" { + os.Remove(filepath.Join(tmpDir, file.Name())) + count++ + } + } + if count > 0 { + logger.Infof("[System] 清理了 %d 个残留的任务日志临时文件", count) + } +} diff --git a/internal/services/tasks/tiny_log_test.go b/internal/services/tasks/tiny_log_test.go new file mode 100644 index 0000000..9a18069 --- /dev/null +++ b/internal/services/tasks/tiny_log_test.go @@ -0,0 +1,104 @@ +package tasks + +import ( + "bytes" + "testing" +) + +func TestTinyLog_UTF8Splitting(t *testing.T) { + tl, err := NewTinyLog("test-utf8", nil) + if err != nil { + t.Fatalf("Failed to create TinyLog: %v", err) + } + defer tl.Close() + + // "你好" in UTF-8: E4 BD A0, E5 a5 bd + part1 := []byte{0xE4, 0xBD} // Partial "你" + part2 := []byte{0xA0, 0xE5, 0xA5} // Rest of "你", partial "好" + part3 := []byte{0xBD, '\n'} // Rest of "好", newline + + _, _ = tl.Write(part1) + if len(tl.remainder) != 2 { + t.Errorf("Expected remainder len 2, got %d", len(tl.remainder)) + } + + _, _ = tl.Write(part2) + // Currently it should collect both parts but still no newline, + // so remainder should be 5 bytes. + if len(tl.remainder) != 5 { + t.Errorf("Expected remainder len 5, got %d", len(tl.remainder)) + } + + _, _ = tl.Write(part3) + if len(tl.remainder) != 0 { + t.Errorf("Expected remainder len 0 after newline, got %d", len(tl.remainder)) + } + + // Read and verify + data, err := tl.ReadLastLines(1) + if err != nil { + t.Fatalf("ReadLastLines failed: %v", err) + } + if !bytes.Contains(data, []byte("你好")) { + t.Errorf("Expected output to contain '你好', got %q", data) + } +} + +func TestTinyLog_CarriageReturn(t *testing.T) { + tl, err := NewTinyLog("test-cr", nil) + if err != nil { + t.Fatalf("Failed to create TinyLog: %v", err) + } + defer tl.Close() + + input := []byte("progress: 50%\rprogress: 100%\r\n") + _, _ = tl.Write(input) + + data, err := tl.ReadLastLines(10) + if err != nil { + t.Fatalf("ReadLastLines failed: %v", err) + } + + // Should contain both progress lines (or at least be split correctly) + if !bytes.Contains(data, []byte("progress: 50%")) { + t.Errorf("Expected output to contain 'progress: 50%%', got %q", data) + } + if !bytes.Contains(data, []byte("progress: 100%")) { + t.Errorf("Expected output to contain 'progress: 100%%', got %q", data) + } +} + +func TestTinyLog_LongLineCut(t *testing.T) { + tl, err := NewTinyLog("test-long", nil) + if err != nil { + t.Fatalf("Failed to create TinyLog: %v", err) + } + defer tl.Close() + + // Create a buffer of maxLogBufferLen-1 bytes with a multi-byte character at the maxLogBufferLen boundary + // We want to ensure it doesn't cut in the middle of a 3-byte char. + longData := make([]byte, maxLogBufferLen-1) + for i := range longData { + longData[i] = 'A' + } + // "你" is E4 BD A0 + longData = append(longData, 0xE4, 0xBD, 0xA0) // This starts at index maxLogBufferLen-1. + // Index maxLogBufferLen-1: E4 + // Index maxLogBufferLen: BD + // Index maxLogBufferLen+1: A0 + // If we cut at maxLogBufferLen, we split E4 and BD. + + _, _ = tl.Write(longData) + + // Since it's > maxLogBufferLen and no newline, it should trigger the cut. + // Our logic finds the last safe boundary before maxLogBufferLen. + // RuneStart(E4) at maxLogBufferLen-1 is true. FullRune(E4 at maxLogBufferLen-1 in payload[:maxLogBufferLen]) is false. + // So lastSafe should be maxLogBufferLen-1. + + // The first maxLogBufferLen-1 bytes (all 'A') should be processed. + // The "你" should be in remainder. + + if len(tl.remainder) != 3 { + t.Errorf("Expected remainder len 3 (the char '你'), got %d", len(tl.remainder)) + } +} diff --git a/internal/services/user_service.go b/internal/services/user_service.go new file mode 100644 index 0000000..b228005 --- /dev/null +++ b/internal/services/user_service.go @@ -0,0 +1,150 @@ +package services + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/utils" + + "gorm.io/gorm" +) + +type UserService struct{} + +func NewUserService() *UserService { + return &UserService{} +} + +func (us *UserService) hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (us *UserService) legacyHashPassword(password string) string { + hash := sha256.Sum256([]byte(password + constant.Secret)) + return hex.EncodeToString(hash[:]) +} + +func (us *UserService) CreateUser(username, password, email, role string) *models.User { + hashedPassword, _ := us.hashPassword(password) + user := &models.User{ + ID: utils.GenerateID(), + Username: username, + Password: hashedPassword, + Email: email, + Role: role, + TokenVersion: 1, + } + database.DB.Create(user) + return user +} + +func (us *UserService) GetUserByUsername(username string) *models.User { + var user models.User + res := database.DB.Where("username = ?", username).Limit(1).Find(&user) + if res.Error != nil || res.RowsAffected == 0 { + return nil + } + return &user +} + +func (us *UserService) GetUserByID(id string) (*models.User, error) { + var user models.User + res := database.DB.Where("id = ?", id).Limit(1).Find(&user) + if res.Error != nil || res.RowsAffected == 0 { + return nil, res.Error + } + return &user, nil +} + +func (us *UserService) ValidatePassword(user *models.User, password string) bool { + // 尝试 bcrypt 校验 + err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err == nil { + return true + } + + // 如果 bcrypt 失败,检查是否为旧的 SHA256 格式 + // 旧格式是 64 位十六进制字符串 + if len(user.Password) == 64 && !strings.HasPrefix(user.Password, "$2") { + if user.Password == us.legacyHashPassword(password) { + // 校验成功,迁移到 bcrypt + newHash, err := us.hashPassword(password) + if err == nil { + database.DB.Model(user).Update("password", newHash) + } + return true + } + } + + return false +} + +func (us *UserService) EnsureAdminExists() { + var count int64 + database.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count) + if count == 0 { + us.CreateUser("admin", "admin123", "admin@local", "admin") + } +} + +func (us *UserService) AuthenticateUser(username, password string) bool { + user := us.GetUserByUsername(username) + if user == nil { + return false + } + return us.ValidatePassword(user, password) +} + +func (us *UserService) UpdatePassword(userID string, newPassword string) error { + hashedPassword, err := us.hashPassword(newPassword) + if err != nil { + return err + } + // 修改密码时同时失效旧 Token + return database.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "password": hashedPassword, + "token_version": gorm.Expr("token_version + 1"), + }).Error +} + +func (us *UserService) InvalidateUserTokens(userID string) error { + return database.DB.Model(&models.User{}).Where("id = ?", userID).Update("token_version", gorm.Expr("token_version + 1")).Error +} + +func (us *UserService) UpdateAccount(userID string, newUsername string) error { + var user models.User + res := database.DB.Where("id = ?", userID).Limit(1).Find(&user) + if res.Error != nil || res.RowsAffected == 0 { + return fmt.Errorf("未找到对应的用户") + } + + updates := make(map[string]interface{}) + if newUsername != "" && newUsername != user.Username { + // 检查用户名是否已存在 + var count int64 + database.DB.Model(&models.User{}).Where("username = ? AND id <> ?", newUsername, userID).Count(&count) + if count > 0 { + return fmt.Errorf("用户名 [%s] 已被占用", newUsername) + } + updates["username"] = newUsername + // 用户名变更,必须失效所有 Token,因为 Token 中包含 Username 且中间件会校验 + updates["token_version"] = gorm.Expr("token_version + 1") + } + + if len(updates) == 0 { + return nil + } + + return database.DB.Model(&user).Updates(updates).Error +} diff --git a/internal/services/webui_service.go b/internal/services/webui_service.go new file mode 100644 index 0000000..2a07f03 --- /dev/null +++ b/internal/services/webui_service.go @@ -0,0 +1,181 @@ +package services + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/utils" +) + +type WebUIService struct { + settingsService *SettingsService +} + +func NewWebUIService(settingsService *SettingsService) *WebUIService { + return &WebUIService{ + settingsService: settingsService, + } +} + +// GetActiveWebUIFS 返回当前激活WebUI的 fs.FS 接口。 +// 如果激活的WebUI是 "default" 或者不存在,则返回 nil。 +func (s *WebUIService) GetActiveWebUIFS() fs.FS { + activeWebUI := s.settingsService.Get(constant.SectionSite, constant.KeyActiveWebUI) + if activeWebUI == "" || activeWebUI == "default" { + return nil + } + + webuiDir := filepath.Join(constant.DataDir, "webuis", activeWebUI) + + // 检查是否存在 uimanifest.json 以确认这是一个有效的WebUI目录 + if _, err := os.Stat(filepath.Join(webuiDir, "uimanifest.json")); os.IsNotExist(err) { + return nil + } + + return os.DirFS(webuiDir) +} + +// WebUIManifest 代表 uimanifest.json 中的元数据 +type WebUIManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + MinPanelVersion string `json:"min_panel_version"` +} + +// GetWebUIs 获取所有可用的WebUI列表 +func (s *WebUIService) GetWebUIs() ([]WebUIManifest, error) { + // 默认WebUI总是可用的 + webuis := []WebUIManifest{ + { + Name: "default", + Version: "builtin", + Author: "TaskPool", + Description: "内置默认WebUI", + }, + } + + records := s.settingsService.GetSection("webui") + for name, val := range records { + var manifest WebUIManifest + if err := json.Unmarshal([]byte(val), &manifest); err == nil { + manifest.Name = name // 强制名称匹配 + webuis = append(webuis, manifest) + } + } + + return webuis, nil +} + +// ExtractWebUI 将 zip 或 tar.gz 压缩包解压到WebUI目录 +func (s *WebUIService) ExtractWebUI(zipPath string) (string, error) { + // 1. 创建临时解压目录(放在 DataDir 下避免跨分区移动失败) + baseWebUIDir := filepath.Join(constant.DataDir, "webuis") + if err := os.MkdirAll(baseWebUIDir, 0755); err != nil { + return "", fmt.Errorf("无法创建 WebUI 基础目录: %v", err) + } + tmpDir, err := os.MkdirTemp(baseWebUIDir, "tmp-webui-*") + if err != nil { + return "", fmt.Errorf("无法创建临时解压目录: %v", err) + } + // 确保在出错时清理临时目录 + defer os.RemoveAll(tmpDir) + + // 2. 根据后缀名选择解压方法 + var extractErr error + if strings.HasSuffix(strings.ToLower(zipPath), ".tar.gz") || strings.HasSuffix(strings.ToLower(zipPath), ".tgz") { + extractErr = utils.ExtractTarGz(zipPath, tmpDir) + } else { + extractErr = utils.ExtractZip(zipPath, tmpDir) + } + if extractErr != nil { + return "", fmt.Errorf("解压WebUI包失败: %v", extractErr) + } + + // 3. 读取并解析 uimanifest.json + manifestPath := filepath.Join(tmpDir, "uimanifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("webui package must contain a uimanifest.json file") + } + return "", fmt.Errorf("无法读取 uimanifest.json: %v", err) + } + + var webuiManifest WebUIManifest + if err := json.Unmarshal(manifestData, &webuiManifest); err != nil { + return "", fmt.Errorf("invalid uimanifest.json format") + } + + webuiName := webuiManifest.Name + if webuiName == "" || webuiName == "default" { + return "", fmt.Errorf("invalid webui name in uimanifest.json") + } + + // 确保WebUI名称安全,防止目录穿越 + webuiName = filepath.Base(filepath.Clean(webuiName)) + + // 4. 确保压缩包中包含 index.html 入口文件 + if _, err := os.Stat(filepath.Join(tmpDir, "index.html")); os.IsNotExist(err) { + return "", fmt.Errorf("webui package must contain an index.html file") + } + + // 5. 移动临时目录到最终的目标目录 + targetDir := filepath.Join(constant.DataDir, "webuis", webuiName) + // 如果目标目录已存在,先删除旧版本 + os.RemoveAll(targetDir) + if err := os.Rename(tmpDir, targetDir); err != nil { + return "", fmt.Errorf("覆盖安装WebUI失败: %v", err) + } + + // 6. 将记录保存到 settings 表中 + manifestJSON, _ := json.Marshal(webuiManifest) + if err := s.settingsService.Set("webui", webuiName, string(manifestJSON)); err != nil { + // 回滚 + os.RemoveAll(targetDir) + return "", fmt.Errorf("保存WebUI记录失败: %v", err) + } + + return webuiName, nil +} + +// DeleteWebUI 删除自定义WebUI +func (s *WebUIService) DeleteWebUI(name string) error { + if name == "" || name == "default" { + return fmt.Errorf("cannot delete default webui") + } + + name = filepath.Base(filepath.Clean(name)) + targetDir := filepath.Join(constant.DataDir, "webuis", name) + + activeWebUI := s.settingsService.Get(constant.SectionSite, constant.KeyActiveWebUI) + if activeWebUI == name { + return fmt.Errorf("cannot delete currently active webui") + } + + if err := os.RemoveAll(targetDir); err != nil { + return err + } + + // 从 settings 表中移除记录 + return s.settingsService.Delete("webui", name) +} + +// SetActiveWebUI 设置当前的活动WebUI +func (s *WebUIService) SetActiveWebUI(name string) error { + if name != "default" { + name = filepath.Base(filepath.Clean(name)) + targetDir := filepath.Join(constant.DataDir, "webuis", name) + if _, err := os.Stat(filepath.Join(targetDir, "uimanifest.json")); os.IsNotExist(err) { + return fmt.Errorf("webui %s not found", name) + } + } + + return s.settingsService.Set(constant.SectionSite, constant.KeyActiveWebUI, name) +} diff --git a/internal/static/changelog.md b/internal/static/changelog.md new file mode 100644 index 0000000..97b1dff --- /dev/null +++ b/internal/static/changelog.md @@ -0,0 +1,34 @@ +# 更新日志 ☕ + +本页面记录了任务池的主要版本更新历史。 + +## 最近更新概览 + +### 2026.03.19 - 仓库同步增强 (ql repo 兼容) +- **青龙指令兼容**:深度支持青龙仓库格式指令,支持自动解析脚本注释并同步为面板任务。 +- **高级过滤逻辑**:支持基于正则的白名单、黑名单及依赖文件匹配特性。 +- **解析器优化**:优化了定时任务 Cron 规则解析逻辑,修正了带 `#` 注释行的识别问题。 + +### 2026.03.05 - API 文档重构 +- **OpenAPI 认证体系**:支持站点级 Token 配置与 Basic Auth 保护。 +- **自定义 UI**:新增设计感十足的全局 **404 页面**。 + +### 2026.03.04 - 消息推送系统重构 +- **原生内置**:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道。 +- **事件捕获**:接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史。 + +### 2026.02.13 - 任务执行引擎重构 +- **深度集成 Mise**:支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换。 +- **依赖管理**:同步上线跨语言统一依赖管理系统。 + +### 2026.02.11 - 安全性增强 +- **随机密码策略**:首次启动使用随机密码并打印在日志中。 +- **暴力破解防护**:登录接口增加防暴力破解。 +- **路径遍历防护**:文件系统操作增加路径穿越锁定。 + +### 2026.02.10 - 任务调度重构 +- **调度性能**:重写了并发控制逻辑,完善了任务队列。 +- **体验优化**:优化文件树交互体验,支持任务执行实时日志流。 + +### 2026.02.06 - 镜像扩展 +- **Debian 13 支持**:增加对 Debian 13 (Trixie) 镜像支持,整理 Docker 目录结构。 diff --git a/internal/static/embed.go b/internal/static/embed.go new file mode 100644 index 0000000..d1e6675 --- /dev/null +++ b/internal/static/embed.go @@ -0,0 +1,25 @@ +//go:build web + +package static + +import ( + "embed" + "io/fs" +) + +//go:embed dist/* +var distFS embed.FS + +// GetFS 返回嵌入的 fs.FS +func GetFS() fs.FS { + subFS, err := fs.Sub(distFS, "dist") + if err != nil { + return nil + } + return subFS +} + +// ReadFile 读取嵌入的文件 +func ReadFile(name string) ([]byte, error) { + return distFS.ReadFile("dist/" + name) +} diff --git a/internal/static/no_embed.go b/internal/static/no_embed.go new file mode 100644 index 0000000..5a13c91 --- /dev/null +++ b/internal/static/no_embed.go @@ -0,0 +1,42 @@ +//go:build !web + +package static + +import ( + "io/fs" + "os" + "path/filepath" +) + +// DefaultStaticDir 是 Docker 模式下默认的静态资源目录 +const DefaultStaticDir = "/www/taskpool" + +func GetFS() fs.FS { + dir := getStaticDir() + if dir == "" { + return nil + } + return os.DirFS(dir) +} + +func ReadFile(name string) ([]byte, error) { + dir := getStaticDir() + if dir == "" { + return nil, os.ErrNotExist + } + return os.ReadFile(filepath.Join(dir, name)) +} + +func getStaticDir() string { + // 优先从环境变量读取 + if dir := os.Getenv("BH_STATIC_DIR"); dir != "" { + if _, err := os.Stat(dir); err == nil { + return dir + } + } + // 回退到默认目录 + if _, err := os.Stat(DefaultStaticDir); err == nil { + return DefaultStaticDir + } + return "" +} diff --git a/internal/systime/systime.go b/internal/systime/systime.go new file mode 100644 index 0000000..389eb91 --- /dev/null +++ b/internal/systime/systime.go @@ -0,0 +1,31 @@ +package systime + +import "time" + +// CST 东八区时区 +var CST = time.FixedZone("CST", 8*3600) + +// Now 返回东八区的当前时间 +func Now() time.Time { + return time.Now().In(CST) +} + +// InCST 将给定时间转换为东八区时间 +func InCST(t time.Time) time.Time { + return t.In(CST) +} + +// FormatTime 将时间格式化为标准格式 +func FormatTime(t time.Time) string { + return InCST(t).Format("2006-01-02 15:04:05") +} + +// FormatDate 将时间格式化为日期格式 +func FormatDate(t time.Time) string { + return InCST(t).Format("2006-01-02") +} + +// FormatDatetime 用于备份等文件命名格式 +func FormatDatetime(t time.Time) string { + return InCST(t).Format("20060102_150405") +} diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go new file mode 100644 index 0000000..18d7526 --- /dev/null +++ b/internal/tunnel/client.go @@ -0,0 +1,265 @@ +package tunnel + +import ( + "crypto/tls" + "fmt" + "math/rand" + "net/http" + "net/url" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/services" + "github.com/gorilla/websocket" + "github.com/hashicorp/yamux" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +var ( + tunnelActive bool + clientConn *yamux.Session + clientMu sync.Mutex + stopCh chan struct{} + + // LocalEngine 保存全局的 Gin 引擎或 HTTP 处理器,用于接收主节点的隧道请求并进行纯内存函数路由 + LocalEngine http.Handler +) + +// SetLocalEngine 由 bootstrap 层注入 +func SetLocalEngine(engine http.Handler) { + LocalEngine = engine +} + +// StartClient 尝试连接到父节点面板(如果已配置) +func StartClient() { + clientMu.Lock() + if tunnelActive { + clientMu.Unlock() + return + } + tunnelActive = true + stopCh = make(chan struct{}) + clientMu.Unlock() + + go runClientLoop() +} + +// StopClient 停止后台的隧道客户端 +func StopClient() { + clientMu.Lock() + if !tunnelActive { + clientMu.Unlock() + return + } + tunnelActive = false + if stopCh != nil { + close(stopCh) + } + if clientConn != nil { + clientConn.Close() + } + clientMu.Unlock() +} + +// Init 从数据库加载配置并初始化对应的后台隧道服务 +func Init() { + siteConfig := services.NewSettingsService().GetSection(constant.SectionInterconnect) + role := siteConfig[constant.KeyInterconnectRole] + ApplyRole(role) +} + +// ApplyRole 根据互联角色动态启停背景协程服务 +func ApplyRole(role string) { + switch role { + case constant.InterconnectRoleMaster: + // 主控角色:无需后台轮询,等待子节点上报即可 + StopClient() + case constant.InterconnectRoleChild: + // 子节点:关闭可能存在的主控会话,启动连接守护 + CloseAllSessions() + StartClient() + default: + // 未开启或离线 + StopClient() + CloseAllSessions() + } +} + +// IsTunnelConnected 返回当前子节点隧道是否已连接到主节点 +func IsTunnelConnected() bool { + clientMu.Lock() + defer clientMu.Unlock() + if !tunnelActive || clientConn == nil { + return false + } + return !clientConn.IsClosed() +} + +func runClientLoop() { + settingsSvc := services.NewSettingsService() + + const ( + initialBackoff = 5 * time.Second + maxBackoff = 300 * time.Second + fatalBackoff = 60 * time.Second + ) + + backoff := initialBackoff + retryCount := 0 + var lastErrorMsg string + + for { + clientMu.Lock() + if !tunnelActive { + clientMu.Unlock() + return + } + clientMu.Unlock() + + siteConfig := settingsSvc.GetSection(constant.SectionInterconnect) + + role := siteConfig[constant.KeyInterconnectRole] + if role != constant.InterconnectRoleChild { + time.Sleep(10 * time.Second) + continue + } + + parentURL := siteConfig[constant.KeyInterconnectParentURL] + parentToken := siteConfig[constant.KeyInterconnectParentToken] + + if parentURL == "" || parentToken == "" { + time.Sleep(10 * time.Second) + continue + } + + u, err := url.Parse(parentURL) + if err != nil { + logger.Errorf("[Tunnel] 无效的主节点 URL: %v", err) + time.Sleep(10 * time.Second) + continue + } + + scheme := "ws" + if u.Scheme == "https" { + scheme = "wss" + } + + wsURL := fmt.Sprintf("%s://%s/api/v1/interconnect/tunnel", scheme, u.Host) + + header := http.Header{} + header.Set("Authorization", "Bearer "+parentToken) + + dialer := &websocket.Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: 45 * time.Second, + } + dialer.EnableCompression = true + if u.Scheme == "https" { + dialer.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + + conn, resp, err := dialer.Dial(wsURL, header) + if err != nil { + retryCount++ + errMsg := err.Error() + + // 特殊处理致命的认证或配置错误 (仅401作为致命错误,403使用指数退避) + isFatal := resp != nil && resp.StatusCode == 401 + if isFatal { + backoff = fatalBackoff + } + + // 先计算退避时间 (加上 0-8 秒随机抖动防止风暴) + jitter := time.Duration(rand.Intn(8000)) * time.Millisecond + sleepDuration := backoff + jitter + + // 日志记录 + if retryCount == 1 || errMsg != lastErrorMsg { + logger.Errorf("[Tunnel] 连接主节点隧道失败: %v, 下次重试将在 %v 后", err, sleepDuration.Round(time.Second)) + } else { + logger.Warnf("[Tunnel] 仍无法连接主节点,已尝试 %d 次, 下次重试将在 %v 后", retryCount, sleepDuration.Round(time.Second)) + } + + lastErrorMsg = errMsg + + time.Sleep(sleepDuration) + + // 指数级增加退避时间 (最大不超过 maxBackoff) + if !isFatal && backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + continue + } + + // 连接成功,重置退避状态 + retryCount = 0 + lastErrorMsg = "" + backoff = initialBackoff + + logger.Infof("[Tunnel] 已成功连接到主节点隧道: %s", wsURL) + + netConn := NetConn(conn) + trackedConn := &trafficCounterConn{Conn: netConn} + conf := yamux.DefaultConfig() + conf.EnableKeepAlive = true + + // 我们是子节点,接受来自主节点的请求。 + // 因此在这里,我们是 Yamux Server,负责监听传入的流。 + session, err := yamux.Server(trackedConn, conf) + if err != nil { + logger.Errorf("[Tunnel] 启动 Yamux Server 失败: %v", err) + trackedConn.Close() + time.Sleep(5 * time.Second) + continue + } + + // 如果已经存在上一个活跃的隧道连接,在重建前必须将其显式 Close(),以释放该会话在后台运行的 HTTP 监听与监听协程 + clientMu.Lock() + if clientConn != nil { + clientConn.Close() + } + clientConn = session + clientMu.Unlock() + + services.GetSystemWSManager().Broadcast(constant.EventInterconnectChildStatus, map[string]interface{}{ + "connected": true, + }) + + // 启动子节点主动上报服务 + StartReporter(parentURL, parentToken) + + // 启动本地 HTTP 代理服务 (阻塞直到会话关闭) + serveLocalProxy(session) + + // 断开时停止上报 + StopReporter() + + session.Close() + services.GetSystemWSManager().Broadcast(constant.EventInterconnectChildStatus, map[string]interface{}{ + "connected": false, + }) + logger.Infof("[Tunnel] 已从主节点隧道断开连接") + + // 断开后稍微缓冲一下再去重试 + time.Sleep(2 * time.Second) + } +} + + diff --git a/internal/tunnel/conn.go b/internal/tunnel/conn.go new file mode 100644 index 0000000..281a349 --- /dev/null +++ b/internal/tunnel/conn.go @@ -0,0 +1,98 @@ +package tunnel + +import ( + "io" + "net" + "time" + + "github.com/gorilla/websocket" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +// wsConn 包装 gorilla websocket 以实现 net.Conn 接口 +type wsConn struct { + conn *websocket.Conn + reader io.Reader +} + +// NetConn 将 WebSocket 连接转换为标准 net.Conn +func NetConn(conn *websocket.Conn) net.Conn { + return &wsConn{ + conn: conn, + } +} + +func (c *wsConn) Read(b []byte) (n int, err error) { + for { + if c.reader == nil { + _, r, err := c.conn.NextReader() + if err != nil { + return 0, err + } + c.reader = r + } + n, err = c.reader.Read(b) + if err == io.EOF { + c.reader = nil + if n > 0 { + return n, nil + } + continue + } + return n, err + } +} + +func (c *wsConn) Write(b []byte) (n int, err error) { + // Yamux 保证了对底层连接的序列化写入(不会出现并发写), + // 因此我们这里不需要加互斥锁 (Mutex)。 + w, err := c.conn.NextWriter(websocket.BinaryMessage) + if err != nil { + return 0, err + } + n, err = w.Write(b) + + // 必须关闭 writer 以触发 WebSocket 的消息帧封包发送 + closeErr := w.Close() + if err == nil { + err = closeErr + } + return n, err +} + +func (c *wsConn) Close() error { + return c.conn.Close() +} + +func (c *wsConn) LocalAddr() net.Addr { + return c.conn.LocalAddr() +} + +func (c *wsConn) RemoteAddr() net.Addr { + return c.conn.RemoteAddr() +} + +func (c *wsConn) SetDeadline(t time.Time) error { + if err := c.conn.SetReadDeadline(t); err != nil { + return err + } + return c.conn.SetWriteDeadline(t) +} + +func (c *wsConn) SetReadDeadline(t time.Time) error { + return c.conn.SetReadDeadline(t) +} + +func (c *wsConn) SetWriteDeadline(t time.Time) error { + return c.conn.SetWriteDeadline(t) +} diff --git a/internal/tunnel/proxy.go b/internal/tunnel/proxy.go new file mode 100644 index 0000000..879add9 --- /dev/null +++ b/internal/tunnel/proxy.go @@ -0,0 +1,116 @@ +package tunnel + +import ( + "errors" + "net/http" + "net/http/httputil" + "strings" + "time" + + "github.com/engigu/taskpool/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/hashicorp/yamux" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +// ProxyHTTP 以完全透明的流式转发将 HTTP 请求代理到远程节点 (主节点调用) +func ProxyHTTP(nodeID string, c *gin.Context, targetPath string) error { + sess := GetSession(nodeID) + if sess == nil { + return errors.New("node is offline or tunnel not established") + } + + if sess.Token == "" { + return errors.New("node token not found in session cache") + } + + // 获取真实客户端 IP,传递给子节点用于审计日志等 + clientIP := c.ClientIP() + + director := func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = "tunnel.local" + req.URL.Path = targetPath + // gin 和 httputil 默认会保留并透传 Query 参数 + + req.Header.Set("Authorization", "Bearer "+sess.Token) + // 删除父节点的 Cookie 避免干扰子节点的会话验证 + req.Header.Del("Cookie") + + // 强制加上该 Header,防止出现节点相互代理的死循环 + req.Header.Set("X-Tunnel-Proxy", "true") + + // 传递真实的客户端 IP 给子节点 + if clientIP != "" { + req.Header.Set("X-Forwarded-For", clientIP) + } + } + + proxy := &httputil.ReverseProxy{ + Director: director, + Transport: sess.Transport, // 直接复用 AddSession 时创建的全局连接池,由原生库处理 Context 取消 + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("[Tunnel] 节点 %s 代理隧道请求发生错误: %v", nodeID, err) + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("Tunnel connection error: " + err.Error())) + }, + } + + proxy.ServeHTTP(c.Writer, c.Request) + return nil +} + +// serveLocalProxy 本地服务代理逻辑 (子节点调用) +func serveLocalProxy(session *yamux.Session) { + if LocalEngine == nil { + logger.Errorf("[Tunnel] 无法启动本地代理服务,因为 LocalEngine 尚未注入。") + return + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 安全规则 1:防止互相调用的死循环 + if strings.HasPrefix(r.URL.Path, "/api/v1/interconnect/proxy/") { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Recursive proxy loops are not allowed via tunnel")) + return + } + + // 安全规则已放宽:允许访问所有路径 (包括前端静态资源和 API),以支持主从版本不一致时的完整穿透。 + // 由 LocalEngine (Gin) 自行决定哪些接口需要认证。 + + // 确保保留 X-Tunnel-Proxy 请求头,以防后续逻辑需要判定 + r.Header.Set("X-Tunnel-Proxy", "true") + + // 纯内存函数调用,直接扔给 Gin 引擎,零网络开销! + LocalEngine.ServeHTTP(w, r) + }) + + server := &http.Server{ + Handler: handler, + ReadTimeout: 15 * time.Second, + IdleTimeout: 15 * time.Second, + } + + // 监听会话关闭信号,主动关闭 http.Server 以彻底释放协程 + go func() { + <-session.CloseChan() + server.Close() + }() + + // yamux.Session 实现了标准的 net.Listener 接口! + err := server.Serve(session) + if err != nil && err != http.ErrServerClosed && err != yamux.ErrSessionShutdown { + logger.Errorf("[Tunnel] Yamux 代理服务意外停止: %v", err) + } +} diff --git a/internal/tunnel/reporter.go b/internal/tunnel/reporter.go new file mode 100644 index 0000000..ae41f0e --- /dev/null +++ b/internal/tunnel/reporter.go @@ -0,0 +1,128 @@ +package tunnel + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "sync" + "time" + + "github.com/engigu/taskpool/internal/executor" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/services" + "github.com/robfig/cron/v3" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +var ( + reporterCronID cron.EntryID + isReporting bool + localTunnelURL string + localTunnelURLMutex sync.RWMutex +) + +// GetLocalTunnelURL 返回当前子节点被分配的隧道地址 +func GetLocalTunnelURL() string { + localTunnelURLMutex.RLock() + defer localTunnelURLMutex.RUnlock() + return localTunnelURL +} + +// StartReporter 启动子节点上报守护进程 +func StartReporter(parentURL, token string) { + if isReporting { + return + } + isReporting = true + + // 修整 parentURL 确保正确指向 /api/v1/interconnect/report + // 如果用户填的是 http://192.168.1.100:8000,我们要加上路径 + baseURL := strings.TrimRight(parentURL, "/") + // 注意兼容如果用户带了 /api/v1 + if !strings.HasSuffix(baseURL, "/api/v1") && !strings.Contains(baseURL, "/api/v1") { + baseURL = baseURL + "/api/v1" + } + reportURL := baseURL + "/interconnect/report" + + // 使用全局 SysCron 并立即上报一次,随后每 45 秒循环一次 + id, err := executor.GetSysCron().AddJobWithRun("@every 45s", func() { + reportMonitorData(reportURL, token) + }) + + if err == nil { + reporterCronID = id + } else { + logger.Warnf("[Tunnel] 无法将上报任务加入 SysCron: %v", err) + } +} + +// StopReporter 停止子节点上报守护进程 +func StopReporter() { + if isReporting { + if reporterCronID != 0 { + executor.GetSysCron().RemoveJob(reporterCronID) + reporterCronID = 0 + } + isReporting = false + } +} + +func reportMonitorData(reportURL, token string) { + // 调用单例监控服务获取底层系统状态 + metrics := services.GetMonitorService().GetHostMetrics() + + payload := map[string]interface{}{ + "cpu_percent": metrics.CPUPercent, + "mem_percent": metrics.VMem.UsedPercent, + "disk_percent": metrics.DiskUsage.UsedPercent, + "tx_bytes": GetTxBytes(), + "rx_bytes": GetRxBytes(), + } + + data, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", reportURL, bytes.NewBuffer(data)) + if err != nil { + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + logger.Warnf("[Tunnel] 上报监控数据失败: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Warnf("[Tunnel] 上报监控数据返回异常状态码: %d", resp.StatusCode) + return + } + + var res struct { + Data struct { + TunnelURL string `json:"tunnel_url"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { + if res.Data.TunnelURL != "" { + localTunnelURLMutex.Lock() + localTunnelURL = res.Data.TunnelURL + localTunnelURLMutex.Unlock() + } + } +} diff --git a/internal/tunnel/server.go b/internal/tunnel/server.go new file mode 100644 index 0000000..5346e1a --- /dev/null +++ b/internal/tunnel/server.go @@ -0,0 +1,107 @@ +package tunnel + +import ( + "strings" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/logger" + "github.com/engigu/taskpool/internal/models" + "github.com/engigu/taskpool/internal/services" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "net/http" + "github.com/hashicorp/yamux" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + EnableCompression: true, // 启用协议级别的 Deflate 数据压缩 +} + +func HandleTunnel(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(401, gin.H{"error": "missing authorization"}) + return + } + + tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if tokenStr == "" { + c.JSON(401, gin.H{"error": "invalid token format"}) + return + } + + settingsSvc := services.NewSettingsService() + siteConfig := settingsSvc.GetSection(constant.SectionInterconnect) + role := siteConfig[constant.KeyInterconnectRole] + + if role != constant.InterconnectRoleMaster { + c.JSON(403, gin.H{"error": "interconnect master role not enabled on this server"}) + return + } + + var node models.InterconnectNode + if err := database.DB.Where("token = ?", tokenStr).First(&node).Error; err != nil { + c.JSON(401, gin.H{"error": "invalid interconnect token"}) + return + } + + nodeID := node.ID + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Errorf("[Tunnel] 升级隧道连接失败 (NodeID: %s): %v", nodeID, err) + return + } + + // 将 WebSocket 包装为标准的 net.Conn + netConn := NetConn(conn) + + // 获取客户端真实 IP 和端口以更新连接地址,方便面板显示 + clientIP := c.ClientIP() + port := "unknown" + if parts := strings.Split(netConn.RemoteAddr().String(), ":"); len(parts) > 1 { + port = parts[len(parts)-1] + } + realURL := "tunnel://" + clientIP + ":" + port + + if node.URL != realURL { + database.DB.Model(&node).Update("url", realURL) + } + + // 我们是主节点(父节点),主动发起对子节点的请求。 + // 因此在 Yamux 协议中我们扮演 Client,而子节点扮演 Server。 + conf := yamux.DefaultConfig() + conf.EnableKeepAlive = true + + session, err := yamux.Client(netConn, conf) + if err != nil { + logger.Errorf("[Tunnel] 启动 Yamux Client 失败 (NodeID: %s): %v", nodeID, err) + netConn.Close() + return + } + + AddSession(nodeID, tokenStr, session) + logger.Infof("[Tunnel] 已建立来自子节点的隧道连接: %s (%s)", nodeID, node.Name) + + go func(s *yamux.Session) { + <-s.CloseChan() + logger.Infof("[Tunnel] 子节点隧道连接已断开 (NodeID: %s, IsClosed: %v)", nodeID, s.IsClosed()) + RemoveSession(nodeID, s) + }(session) +} diff --git a/internal/tunnel/session.go b/internal/tunnel/session.go new file mode 100644 index 0000000..825e4d7 --- /dev/null +++ b/internal/tunnel/session.go @@ -0,0 +1,102 @@ +package tunnel + +import ( + "context" + "net" + "net/http" + "sync" + + "github.com/engigu/taskpool/internal/database" + "github.com/engigu/taskpool/internal/models" + "github.com/hashicorp/yamux" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +// TunnelSession 表示一个基于 WebSocket 的 Yamux 活跃会话 +type TunnelSession struct { + NodeID string + Token string + Session *yamux.Session + Transport *http.Transport +} + +var ( + sessions = make(map[string]*TunnelSession) + sessionsMu sync.RWMutex +) + +func AddSession(nodeID string, token string, session *yamux.Session) *TunnelSession { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + // 关闭已经存在的旧会话 + if old, exists := sessions[nodeID]; exists { + old.Session.Close() + old.Transport.CloseIdleConnections() + } + + tr := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return session.Open() + }, + DisableKeepAlives: true, + } + + sess := &TunnelSession{ + NodeID: nodeID, + Token: token, + Session: session, + Transport: tr, + } + sessions[nodeID] = sess + return sess +} + +func RemoveSession(nodeID string, session *yamux.Session) { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + if sess, exists := sessions[nodeID]; exists && sess.Session == session { + sess.Session.Close() + sess.Transport.CloseIdleConnections() + delete(sessions, nodeID) + + // 节点下线时实时更新数据库状态 + database.DB.Model(&models.InterconnectNode{}). + Where("id = ?", nodeID). + Update("status", "offline") + } +} + +// CloseAllSessions 强制关闭所有现存的子节点会话(用于主控角色被取消时) +func CloseAllSessions() { + sessionsMu.Lock() + defer sessionsMu.Unlock() + + for _, sess := range sessions { + if sess.Session != nil { + sess.Session.Close() + } + if sess.Transport != nil { + sess.Transport.CloseIdleConnections() + } + } + // 清空所有的会话 + sessions = make(map[string]*TunnelSession) +} + +func GetSession(nodeID string) *TunnelSession { + sessionsMu.RLock() + defer sessionsMu.RUnlock() + return sessions[nodeID] +} diff --git a/internal/tunnel/traffic.go b/internal/tunnel/traffic.go new file mode 100644 index 0000000..8270721 --- /dev/null +++ b/internal/tunnel/traffic.go @@ -0,0 +1,59 @@ +package tunnel + +import ( + "net" + "sync/atomic" +) + +// Copyright (c) 2026 engigu (TaskPool). All rights reserved. +// Use of this source code is governed by the Apache License 2.0. +// +// 【重要声明 / IMPORTANT NOTICE】 +// 本代码(包括其架构设计与核心实现)属于任务池(TaskPool)开源项目的一部分。 +// 任何个人或组织在引用、移植、修改或重新分发此文件中的任何代码时,必须保留本版权声明, +// 并在您的衍生作品、文档、软件关于页面或说明文件中显式声明引用自任务池(TaskPool)。 +// +// Anyone referencing, porting, modifying, or redistributing this code must retain this +// copyright notice and explicitly state the source: TaskPool (github.com/engigu/taskpool). + +var ( + txBytes uint64 + rxBytes uint64 +) + +// trafficCounterConn 包装了原生的 net.Conn,用于统计底层收发的真实物理字节数 +type trafficCounterConn struct { + net.Conn +} + +func (c *trafficCounterConn) Read(b []byte) (n int, err error) { + n, err = c.Conn.Read(b) + if n > 0 { + atomic.AddUint64(&rxBytes, uint64(n)) + } + return +} + +func (c *trafficCounterConn) Write(b []byte) (n int, err error) { + n, err = c.Conn.Write(b) + if n > 0 { + atomic.AddUint64(&txBytes, uint64(n)) + } + return +} + +// GetTxBytes 返回隧道累计发送的字节数 +func GetTxBytes() uint64 { + return atomic.LoadUint64(&txBytes) +} + +// GetRxBytes 返回隧道累计接收的字节数 +func GetRxBytes() uint64 { + return atomic.LoadUint64(&rxBytes) +} + +// ResetTrafficBytes 重置流量统计计数器 +func ResetTrafficBytes() { + atomic.StoreUint64(&txBytes, 0) + atomic.StoreUint64(&rxBytes, 0) +} diff --git a/internal/utils/archive.go b/internal/utils/archive.go new file mode 100644 index 0000000..0635fa8 --- /dev/null +++ b/internal/utils/archive.go @@ -0,0 +1,225 @@ +package utils + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +func CreateZip(dst io.Writer, basePaths []string) (err error) { + w := zip.NewWriter(dst) + defer func() { + if closeErr := w.Close(); err == nil { + err = closeErr + } + }() + + for _, basePath := range basePaths { + info, err := os.Lstat(basePath) + if err != nil { + return err + } + + if info.Mode()&os.ModeSymlink != 0 { + continue + } + + if info.Mode().IsRegular() { + if err := addZipFile(w, basePath, filepath.Base(basePath), info); err != nil { + return err + } + continue + } + + if info.IsDir() { + baseName := filepath.Base(basePath) + if err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + + rel, err := filepath.Rel(basePath, path) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return nil + } + + name := baseName + if rel != "." { + name = filepath.Join(baseName, rel) + } + name = filepath.ToSlash(name) + + info, err := d.Info() + if err != nil { + return err + } + + if d.IsDir() { + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = name + "/" + _, err = w.CreateHeader(header) + return err + } + + if info.Mode().IsRegular() { + return addZipFile(w, path, name, info) + } + return nil + }); err != nil { + return err + } + } + } + + return nil +} + +func addZipFile(w *zip.Writer, path, name string, info os.FileInfo) error { + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = filepath.ToSlash(name) + header.Method = zip.Deflate + + writer, err := w.CreateHeader(header) + if err != nil { + return err + } + + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err +} + +func ExtractZip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + fpath := filepath.Join(dest, f.Name) + + // 安全检查:防止路径遍历 (ZipSlip) + rel, err := filepath.Rel(dest, fpath) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + continue + } + + if f.FileInfo().IsDir() { + os.MkdirAll(fpath, 0755) + continue + } + + if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + return err + } + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return err + } + + _, err = io.Copy(outFile, rc) + outFile.Close() + rc.Close() + + if err != nil { + return err + } + } + return nil +} + +func ExtractTar(src, dest string) error { + file, err := os.Open(src) + if err != nil { + return err + } + defer file.Close() + + return extractTarReader(tar.NewReader(file), dest) +} + +func ExtractTarGz(src, dest string) error { + file, err := os.Open(src) + if err != nil { + return err + } + defer file.Close() + + gzr, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzr.Close() + + return extractTarReader(tar.NewReader(gzr), dest) +} + +func extractTarReader(tr *tar.Reader, dest string) error { + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + fpath := filepath.Join(dest, header.Name) + + // 安全检查:防止路径遍历 + rel, err := filepath.Rel(dest, fpath) + if err != nil || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + continue + } + + switch header.Typeflag { + case tar.TypeDir: + os.MkdirAll(fpath, 0755) + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + return err + } + + outFile, err := os.Create(fpath) + if err != nil { + return err + } + + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return err + } + outFile.Close() + + os.Chmod(fpath, os.FileMode(header.Mode)) + } + } + return nil +} diff --git a/internal/utils/buffer.go b/internal/utils/buffer.go new file mode 100644 index 0000000..e1ad508 --- /dev/null +++ b/internal/utils/buffer.go @@ -0,0 +1,79 @@ +package utils + +import ( + "fmt" + "sync" +) + +// TailBuffer 是一个只保留最后 N 字节数据的缓冲区 +type TailBuffer struct { + mu sync.Mutex + limit int + data []byte + size int // 当前实际存储的大小 + pos int // 下一个写入位置 (针对环形缓冲区逻辑,但这里为了简单使用切片重排) +} + +// NewTailBuffer 创建一个限制大小为 limit 的尾部缓冲区 +func NewTailBuffer(limit int) *TailBuffer { + return &TailBuffer{ + limit: limit, + data: make([]byte, 0, limit), + } +} + +// Write 实现 io.Writer 接口 +func (b *TailBuffer) Write(p []byte) (n int, err error) { + b.mu.Lock() + defer b.mu.Unlock() + + n = len(p) + if n >= b.limit { + // 如果单次写入就超过了限制,直接取最后 limit 字节 + b.data = append(b.data[:0], p[n-b.limit:]...) + return + } + + available := b.limit - len(b.data) + if n <= available { + // 空间足够,直接追加 + b.data = append(b.data, p...) + } else { + // 空间不足,需要移除旧数据 + toRemove := n - available + b.data = append(b.data[toRemove:], p...) + } + return +} + +// Bytes 返回缓冲区内的所有数据 +func (b *TailBuffer) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + res := make([]byte, len(b.data)) + copy(res, b.data) + return res +} + +// String 返回缓冲区内的字符串表示 +func (b *TailBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.data) +} + +// Len 返回当前存储的数据长度 +func (b *TailBuffer) Len() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.data) +} + +// TrimLog 裁剪日志,保留末尾指定大小 +func TrimLog(content string, limit int) string { + if len(content) <= limit { + return content + } + // 简单裁剪,不考虑字符完整性,因为这是针对大文本的保护 + return fmt.Sprintf("\n\n[System] 日志过长,已自动截断,仅保留末尾 %d MB...\n\n", limit/1024/1024) + content[len(content)-limit:] +} diff --git a/internal/utils/compress.go b/internal/utils/compress.go new file mode 100644 index 0000000..7a7c182 --- /dev/null +++ b/internal/utils/compress.go @@ -0,0 +1,134 @@ +package utils + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "io" + "strings" + "sync" + + "github.com/klauspost/compress/zstd" +) + +const ( + zstdPrefix = "zstd:" + rawPrefix = "raw:" + MinCompressSize = 128 +) + +var zlibWriterPool = sync.Pool{ + New: func() interface{} { + return zlib.NewWriter(io.Discard) + }, +} + +// GetZlibWriter 从对象池中获取 zlib 写入器 +func GetZlibWriter(w io.Writer) *zlib.Writer { + zw := zlibWriterPool.Get().(*zlib.Writer) + zw.Reset(w) + return zw +} + +// PutZlibWriter 将 zlib 写入器还回对象池 +func PutZlibWriter(zw *zlib.Writer) { + zlibWriterPool.Put(zw) +} + +var zstdEncoderPool = sync.Pool{ + New: func() interface{} { + zw, _ := zstd.NewWriter(nil) + return zw + }, +} + +// GetZstdWriter 从对象池中获取 zstd 写入器并定向到 w +func GetZstdWriter(w io.Writer) *zstd.Encoder { + zw := zstdEncoderPool.Get().(*zstd.Encoder) + zw.Reset(w) + return zw +} + +// PutZstdWriter 将 zstd 写入器还回对象池 +func PutZstdWriter(zw *zstd.Encoder) { + zstdEncoderPool.Put(zw) +} + +var ( + zstdEncoder *zstd.Encoder + zstdDecoder *zstd.Decoder + zstdOnce sync.Once +) + +func initZstd() { + zstdOnce.Do(func() { + var err error + // 默认级别适合常规压缩 + zstdEncoder, err = zstd.NewWriter(nil) + if err != nil { + panic(err) + } + zstdDecoder, err = zstd.NewReader(nil) + if err != nil { + panic(err) + } + }) +} + +// CompressToBase64 compresses data using zstd and encodes to base64 with a prefix (uses raw for data under MinCompressSize bytes) +func CompressToBase64(data string) (string, error) { + if data == "" { + return "", nil + } + + // 小于等于阈值,不需要压缩,直接前缀明文保存 + if len(data) <= MinCompressSize { + return rawPrefix + data, nil + } + + initZstd() + compressed := zstdEncoder.EncodeAll([]byte(data), nil) + return zstdPrefix + base64.StdEncoding.EncodeToString(compressed), nil +} + +// DecompressFromBase64 decodes base64 and decompresses data (supports raw/zstd prefix and falls back to zlib) +func DecompressFromBase64(data string) (string, error) { + if data == "" { + return "", nil + } + + if strings.HasPrefix(data, rawPrefix) { + return data[len(rawPrefix):], nil + } + + if strings.HasPrefix(data, zstdPrefix) { + initZstd() + encoded := data[len(zstdPrefix):] + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + decompressed, err := zstdDecoder.DecodeAll(decoded, nil) + if err != nil { + return "", err + } + return string(decompressed), nil + } + + // Fallback to legacy zlib + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + zr, err := zlib.NewReader(bytes.NewReader(decoded)) + if err != nil { + return "", err + } + defer zr.Close() + result, err := io.ReadAll(zr) + if err != nil { + return "", err + } + return string(result), nil +} + diff --git a/internal/utils/compress_test.go b/internal/utils/compress_test.go new file mode 100644 index 0000000..974d558 --- /dev/null +++ b/internal/utils/compress_test.go @@ -0,0 +1,121 @@ +package utils + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "strings" + "testing" +) + +// legacyZlibCompress 以前的 zlib 压缩逻辑,用于构造测试样本 +func legacyZlibCompress(data string) (string, error) { + if data == "" { + return "", nil + } + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + if _, err := zw.Write([]byte(data)); err != nil { + return "", err + } + if err := zw.Close(); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +func TestCompressAndDecompressZstd(t *testing.T) { + originalText := "Hello, this is a test log message for ZSTD compression in TaskPool! Repeat: Hello, this is a test log message for ZSTD compression in TaskPool!" + + // 1. 测试 ZSTD 压缩 + compressed, err := CompressToBase64(originalText) + if err != nil { + t.Fatalf("CompressToBase64 failed: %v", err) + } + + // 验证前缀是否正确 + if !strings.HasPrefix(compressed, "zstd:") { + t.Errorf("Expected compressed output to have 'zstd:' prefix, got: %s", compressed) + } + + // 2. 测试 ZSTD 解密解压 + decompressed, err := DecompressFromBase64(compressed) + if err != nil { + t.Fatalf("DecompressFromBase64 failed: %v", err) + } + + if decompressed != originalText { + t.Errorf("Decompressed text mismatch.\nExpected: %s\nGot: %s", originalText, decompressed) + } +} + +func TestDecompressLegacyZlibCompatibility(t *testing.T) { + originalText := "This is a legacy log message compressed using zlib. It should be decompressed successfully." + + // 1. 用老逻辑压缩生成旧数据 + legacyCompressed, err := legacyZlibCompress(originalText) + if err != nil { + t.Fatalf("legacyZlibCompress failed: %v", err) + } + + // 验证没有 zstd 前缀 + if strings.HasPrefix(legacyCompressed, "zstd:") { + t.Fatalf("Legacy compressed string shouldn't have 'zstd:' prefix") + } + + // 2. 使用新版的 DecompressFromBase64 解压,验证其对旧格式的兼容性 + decompressed, err := DecompressFromBase64(legacyCompressed) + if err != nil { + t.Fatalf("DecompressFromBase64 failed to decompress legacy data: %v", err) + } + + if decompressed != originalText { + t.Errorf("Decompressed legacy text mismatch.\nExpected: %s\nGot: %s", originalText, decompressed) + } +} + +func TestEmptyString(t *testing.T) { + compressed, err := CompressToBase64("") + if err != nil { + t.Fatalf("CompressToBase64 for empty string failed: %v", err) + } + if compressed != "" { + t.Errorf("Expected empty string for empty input, got: %q", compressed) + } + + decompressed, err := DecompressFromBase64("") + if err != nil { + t.Fatalf("DecompressFromBase64 for empty string failed: %v", err) + } + if decompressed != "" { + t.Errorf("Expected empty string for empty input, got: %q", decompressed) + } +} + +func TestCompressShortTextRaw(t *testing.T) { + shortText := "python secret.py" // 16 bytes + + // 1. 压缩(应转为 raw 前缀) + output, err := CompressToBase64(shortText) + if err != nil { + t.Fatalf("CompressToBase64 short text failed: %v", err) + } + + if !strings.HasPrefix(output, "raw:") { + t.Errorf("Expected short text output to have 'raw:' prefix, got: %q", output) + } + if output != "raw:python secret.py" { + t.Errorf("Expected output raw:python secret.py, got: %q", output) + } + + // 2. 解码解密 + decompressed, err := DecompressFromBase64(output) + if err != nil { + t.Fatalf("DecompressFromBase64 short text failed: %v", err) + } + + if decompressed != shortText { + t.Errorf("Decompressed short text mismatch.\nExpected: %q\nGot: %q", shortText, decompressed) + } +} + diff --git a/internal/utils/crypto.go b/internal/utils/crypto.go new file mode 100644 index 0000000..530ce06 --- /dev/null +++ b/internal/utils/crypto.go @@ -0,0 +1,134 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "os" + "strings" +) + +var ( + masterSecretKey []byte + ErrKeyNotSet = errors.New("加密秘钥未配置,请按照文档使用 TASKPOOL_SECRET_KEY 环境变量启动服务配置秘钥") +) + +// InitSecretKey initialized the master secret key from the environment and unsets it +func InitSecretKey() { + // 优先新变量;兼容旧 BAIHU_SECRET_KEY + key := os.Getenv("TASKPOOL_SECRET_KEY") + if key == "" { + key = os.Getenv("BAIHU_SECRET_KEY") + } + if key != "" { + hash := sha256.Sum256([]byte(key)) + masterSecretKey = hash[:] + // Ensure it's only in memory by unsetting the environment variable + os.Unsetenv("TASKPOOL_SECRET_KEY") + os.Unsetenv("BAIHU_SECRET_KEY") + } +} + +// IsSecretKeySet returns true if the master secret key is configured +func IsSecretKeySet() bool { + return len(masterSecretKey) > 0 +} + +// Encrypt encrypts a plaintext string using AES-GCM +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + if !IsSecretKeySet() { + return "", ErrKeyNotSet + } + + block, err := aes.NewCipher(masterSecretKey) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a ciphertext string using AES-GCM +// Returns the original string if decryption fails or if it wasn't encrypted +func Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + if !IsSecretKeySet() { + return ciphertext, ErrKeyNotSet + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return ciphertext, err + } + + block, err := aes.NewCipher(masterSecretKey) + if err != nil { + return ciphertext, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return ciphertext, err + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return ciphertext, errors.New("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return ciphertext, err + } + + return string(plaintext), nil +} + +// MaskSecrets 将文本中的所有敏感机密值替换为脱敏字符串 "********" +func MaskSecrets(text string, secrets []string) string { + if len(secrets) == 0 || text == "" { + return text + } + for _, mask := range secrets { + if mask != "" { + text = strings.ReplaceAll(text, mask, "********") + } + } + return text +} + +// MaskString 对字符串进行脱敏处理,保留首尾,中间用星号遮掩 +func MaskString(s string) string { + if s == "" { + return "" + } + n := len(s) + if n <= 3 { + return "***" + } + if n <= 6 { + return s[:1] + "***" + s[n-1:] + } + return s[:2] + "****" + s[n-2:] +} diff --git a/internal/utils/encoding.go b/internal/utils/encoding.go new file mode 100644 index 0000000..1a76761 --- /dev/null +++ b/internal/utils/encoding.go @@ -0,0 +1,92 @@ +package utils + +import ( + "bufio" + "io" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +// ToUTF8 converts potentially non-UTF8 data (like GBK on Windows) to UTF-8 +func ToUTF8(data []byte) string { + if utf8.Valid(data) { + return string(data) + } + // Try GBK (common on Windows) + reader := transform.NewReader( + bufio.NewReader( + &byteReader{data: data}, + ), + simplifiedchinese.GBK.NewDecoder(), + ) + result, err := io.ReadAll(reader) + if err != nil { + return string(data) + } + return string(result) +} + +type byteReader struct { + data []byte + pos int +} + +func (r *byteReader) Read(p []byte) (n int, err error) { + if r.pos >= len(r.data) { + return 0, io.EOF + } + n = copy(p, r.data[r.pos:]) + r.pos += n + return n, nil +} + +// TrimLastRunes 从字符串尾部保留最多 maxRunes 个字符(不仅限于 ASCII,支持中英文混排的真实字符数量) +func TrimLastRunes(s string, maxRunes int) string { + // 如果字符串的总字节数小于等于 maxRunes,那么它的字符数一定也小于等于 maxRunes + if len(s) <= maxRunes { + return s + } + + count := 0 + for i := len(s); i > 0; { + _, size := utf8.DecodeLastRuneInString(s[:i]) + i -= size + count++ + if count == maxRunes { + return s[i:] + } + } + return s +} + +// IntToStr 将整数转换为字符串 +func IntToStr(n int) string { + if n == 0 { + return "0" + } + + var negative bool + if n < 0 { + negative = true + n = -n + } + + var digits []byte + for n > 0 { + digits = append(digits, byte('0'+n%10)) + n /= 10 + } + + if negative { + digits = append(digits, '-') + } + + // Reverse + for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { + digits[i], digits[j] = digits[j], digits[i] + } + + return string(digits) +} diff --git a/internal/utils/fs.go b/internal/utils/fs.go new file mode 100644 index 0000000..99ce13b --- /dev/null +++ b/internal/utils/fs.go @@ -0,0 +1,85 @@ +package utils + +import ( + "io" + "os" + "path/filepath" +) + +// CopyPath copies a file or directory from src to dest +func CopyPath(src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + + if info.IsDir() { + return copyDir(src, dest) + } + return CopyFile(src, dest) +} + +// CopyFile copies a single file from src to dest +func CopyFile(src, dest string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return err + } + + destFile, err := os.Create(dest) + if err != nil { + return err + } + defer destFile.Close() + + if _, err := io.Copy(destFile, srcFile); err != nil { + return err + } + + info, err := os.Stat(src) + if err == nil { + os.Chmod(dest, info.Mode()) + } + + return nil +} + +func copyDir(src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + + if err := os.MkdirAll(dest, info.Mode()); err != nil { + return err + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + + if err := CopyPath(srcPath, destPath); err != nil { + return err + } + } + + return nil +} + +// IsInDocker 判断程序是否运行在 Docker 容器中 +func IsInDocker() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + return false +} diff --git a/internal/utils/goid.go b/internal/utils/goid.go new file mode 100644 index 0000000..8a6b3fb --- /dev/null +++ b/internal/utils/goid.go @@ -0,0 +1,20 @@ +package utils + +import ( + "runtime" + "strconv" + "strings" +) + +// GetGoroutineID 获取当前 Goroutine ID +// 注意:这只是为了调试和日志目的,不应该用于业务逻辑 +func GetGoroutineID() int64 { + var buf [64]byte + n := runtime.Stack(buf[:], false) + idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0] + id, err := strconv.ParseInt(idField, 10, 64) + if err != nil { + return -1 + } + return id +} diff --git a/internal/utils/id.go b/internal/utils/id.go new file mode 100644 index 0000000..c141256 --- /dev/null +++ b/internal/utils/id.go @@ -0,0 +1,20 @@ +package utils + +import ( + "github.com/rs/xid" +) + +// GenerateID 生成一个新的 ID (使用 xid,20位字符) +func GenerateID() string { + return xid.New().String() +} + +// IsNumeric 检查字符串是否全为数字 +func IsNumeric(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return s != "" +} diff --git a/internal/utils/machine.go b/internal/utils/machine.go new file mode 100644 index 0000000..1c0145e --- /dev/null +++ b/internal/utils/machine.go @@ -0,0 +1,51 @@ +package utils + +import ( + "crypto/sha256" + "encoding/hex" + "net" + "os" + "runtime" + "sort" + "strings" +) + +// GenerateMachineID 生成机器识别码 +func GenerateMachineID() string { + var parts []string + + // 主机名 + if hostname, err := os.Hostname(); err == nil { + parts = append(parts, hostname) + } + + // 获取所有非回环网卡的 MAC 地址,排序后取第一个(最稳定) + if interfaces, err := net.Interfaces(); err == nil { + var macs []string + for _, iface := range interfaces { + // 跳过回环接口、没有 MAC 地址的接口、虚拟接口 + if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) == 0 { + continue + } + // 跳过 docker/veth 等虚拟网卡 + name := strings.ToLower(iface.Name) + if strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "veth") || + strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "virbr") { + continue + } + macs = append(macs, iface.HardwareAddr.String()) + } + sort.Strings(macs) + // 只使用第一个 MAC 地址(最稳定) + if len(macs) > 0 { + parts = append(parts, macs[0]) + } + } + + // 操作系统和架构 + parts = append(parts, runtime.GOOS, runtime.GOARCH) + + data := strings.Join(parts, "|") + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/utils/mise.go b/internal/utils/mise.go new file mode 100644 index 0000000..a835dcf --- /dev/null +++ b/internal/utils/mise.go @@ -0,0 +1,167 @@ +package utils + +import ( + "os/exec" + "strings" + "sync" +) + +var nodePathCache sync.Map + +// GetMiseNodePath 获取指定版本的 node 全局包路径,使用内存缓存避免重复获取 +func GetMiseNodePath(version string) string { + if version == "" { + version = "latest" + } + + if val, ok := nodePathCache.Load(version); ok { + return val.(string) + } + + cmd := exec.Command("mise", "where", "node@"+version) + out, err := cmd.CombinedOutput() + if err == nil { + nodeDir := strings.TrimSpace(string(out)) + if nodeDir != "" { + // 采用双路径策略:lib/node_modules 是标准路径,lib 是某些环境(如 mise Docker)下的特殊路径 + // 通过冒号分隔,让 Node.js 按顺序搜索,保证最大兼容性 + nodePath := nodeDir + "/lib/node_modules:" + nodeDir + "/lib" + nodePathCache.Store(version, nodePath) + return nodePath + } + } + + return "" +} + +// InjectNodePath 检查语言环境中是否有 node,如果有则自动获取并注入 NODE_PATH 到环境变量切片中 +func InjectNodePath(envs *[]string, languages []map[string]string) { + for _, lang := range languages { + if lang["name"] == "node" { + if nodePath := GetMiseNodePath(lang["version"]); nodePath != "" { + *envs = append(*envs, "NODE_PATH="+nodePath) + } + break + } + } +} + +// BuildMiseCommand 构建多语言 mise 执行命令 (字符串形式) +func BuildMiseCommand(command string, languages []map[string]string) string { + if len(languages) == 0 { + return command + } + + var builder strings.Builder + builder.WriteString("mise exec") + + for _, lang := range languages { + name := lang["name"] + version := lang["version"] + if name == "" { + continue + } + if version == "" { + version = "latest" + } + builder.WriteString(" " + name + "@" + version) + } + + builder.WriteString(" -- " + command) + return builder.String() +} + +// BuildMiseCommandArgs 构建多语言 mise 执行命令 (参数列表形式) +func BuildMiseCommandArgs(cmdArgs []string, languages []map[string]string) []string { + if len(languages) == 0 { + return cmdArgs + } + + args := []string{"mise", "exec"} + for _, lang := range languages { + name := lang["name"] + version := lang["version"] + if name == "" { + continue + } + if version == "" { + version = "latest" + } + args = append(args, name+"@"+version) + } + args = append(args, "--") + args = append(args, cmdArgs...) + return args +} + +// BuildMiseCommandSimple 构建单个语言的 mise 执行命令 +func BuildMiseCommandSimple(command string, language, version string) string { + if language == "" { + return command + } + spec := language + if version != "" { + spec += "@" + version + } + return "mise exec " + spec + " -- " + command +} + +// BuildMiseCommandArgsSimple 构建单个语言的 mise 执行命令 (参数列表形式) +func BuildMiseCommandArgsSimple(cmdArgs []string, language, version string) []string { + if language == "" { + return cmdArgs + } + spec := language + if version != "" { + spec += "@" + version + } + return append([]string{"mise", "exec", spec, "--"}, cmdArgs...) +} + +// ListMiseInstalledVersions 获取指定语言已安装的所有版本列表 +func ListMiseInstalledVersions(language string) ([]string, error) { + // 执行 mise ls 命令 + cmd := exec.Command("mise", "ls", language) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + var versions []string + lines := strings.Split(string(out), "\n") + for _, line := range lines { + v := strings.TrimSpace(line) + if v == "" { + continue + } + // mise ls 的输出可能包含状态标识或插件名,例: + // * 20.10.0 (active) + // node 18.17.0 + fields := strings.Fields(v) + if len(fields) == 0 { + continue + } + + startIdx := 0 + // 跳过状态标识符 + if fields[startIdx] == "*" || fields[startIdx] == "->" || fields[startIdx] == ">" { + startIdx++ + } + + if len(fields) <= startIdx { + continue + } + + vstr := fields[startIdx] + // 如果第一个有效字段是插件名,则版本号在第二个字段 + if vstr == language && len(fields) > startIdx+1 { + vstr = fields[startIdx+1] + } + + // 确保解析出来的不是插件名 + if vstr != "" && vstr != language { + versions = append(versions, vstr) + } + } + return versions, nil +} diff --git a/internal/utils/network.go b/internal/utils/network.go new file mode 100644 index 0000000..24d6c6c --- /dev/null +++ b/internal/utils/network.go @@ -0,0 +1,72 @@ +package utils + +import ( + "net" + "net/http" + "net/url" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +// CheckWSOrigin 校验 WebSocket 的 Origin 来源是否安全。 +// 默认仅允许同源请求,可通过环境变量 BH_ALLOWED_ORIGINS 配置额外的允许列表(逗号分隔)。 +func CheckWSOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // 非浏览器发起的请求(如直接用脚本连接)通常不带 Origin,默认放行。 + return true + } + + u, err := url.Parse(origin) + if err != nil { + return false + } + + // 0. 开发环境校验:如果是非 Release 模式,默认放行 + if gin.Mode() != gin.ReleaseMode { + return true + } + + // 1. 同源校验:Origin 的 Host 与请求头中的 Host 一致 + if strings.EqualFold(u.Host, r.Host) { + return true + } + + // 容错处理:如果因为 Nginx 配置了 $host 而丢掉了端口,导致一方带端口一方不带端口时,尝试忽略端口进行域名比对 + uHostOnly := u.Hostname() + rHostOnly := r.Host + if h, _, err := net.SplitHostPort(r.Host); err == nil { + rHostOnly = h + } + if strings.EqualFold(uHostOnly, rHostOnly) { + return true + } + + // 2. 环境变量配置的允许列表校验 + allowedOrigins := os.Getenv("BH_ALLOWED_ORIGINS") + if allowedOrigins != "" { + origins := strings.Split(allowedOrigins, ",") + for _, o := range origins { + o = strings.TrimSpace(o) + if o == "*" { + return true + } + // 匹配完整 Origin (如 http://localhost:5173) 或仅 Host 部分 + if strings.EqualFold(o, origin) || strings.EqualFold(o, u.Host) { + return true + } + } + } + + // 3. 允许来自 localhost、127.0.0.1 以及局域网内网 IP 的请求 (方便本地开发、虚拟机调试和局域网部署) + hostname := u.Hostname() + if strings.HasPrefix(hostname, "localhost") || strings.HasPrefix(hostname, "127.0.0.1") || + strings.HasPrefix(hostname, "192.168.") || strings.HasPrefix(hostname, "10.") || + strings.HasPrefix(hostname, "172.") { + return true + } + + return false +} diff --git a/internal/utils/pagination.go b/internal/utils/pagination.go new file mode 100644 index 0000000..bfa29b2 --- /dev/null +++ b/internal/utils/pagination.go @@ -0,0 +1,79 @@ +package utils + +import ( + "strconv" + + "github.com/engigu/taskpool/internal/cache" + "github.com/engigu/taskpool/internal/constant" + + "github.com/gin-gonic/gin" +) + +// ToInt 解析字符串为整数,如果解析失败则返回默认值 +func ToInt(s string, defaultVal int) int { + val, err := strconv.Atoi(s) + if err != nil { + return defaultVal + } + return val +} + +// ParseInt 解析字符串为整数 +func ParseInt(s string) (int, error) { + return strconv.Atoi(s) +} + +// Pagination 分页参数 +type Pagination struct { + Page int + PageSize int +} + +// getDefaultPageSize 从缓存获取默认分页大小 +func getDefaultPageSize() int { + pageSizeStr := cache.GetSiteCache(constant.KeyPageSize) + pageSize, err := strconv.Atoi(pageSizeStr) + if err != nil || pageSize < 1 { + return 10 + } + return pageSize +} + +// ParsePagination 从请求中解析分页参数 +func ParsePagination(c *gin.Context) Pagination { + defaultPageSize := getDefaultPageSize() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize))) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 10000 { + pageSize = defaultPageSize + } + + return Pagination{Page: page, PageSize: pageSize} +} + +// Offset 计算偏移量 +func (p Pagination) Offset() int { + return (p.Page - 1) * p.PageSize +} + +// PaginationData 分页数据 +type PaginationData struct { + Data interface{} `json:"data"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// PaginatedResponse 分页响应 +func PaginatedResponse(c *gin.Context, data interface{}, total int64, p Pagination) { + Success(c, PaginationData{ + Data: data, + Total: total, + Page: p.Page, + PageSize: p.PageSize, + }) +} diff --git a/internal/utils/pointer.go b/internal/utils/pointer.go new file mode 100644 index 0000000..6bee81e --- /dev/null +++ b/internal/utils/pointer.go @@ -0,0 +1,14 @@ +package utils + +// BoolPtr returns a pointer to the bool value +func BoolPtr(b bool) *bool { + return &b +} + +// DerefBool returns the value of the bool pointer or default if nil +func DerefBool(b *bool, defaultVal bool) bool { + if b == nil { + return defaultVal + } + return *b +} diff --git a/internal/utils/random.go b/internal/utils/random.go new file mode 100644 index 0000000..bf99e58 --- /dev/null +++ b/internal/utils/random.go @@ -0,0 +1,18 @@ +package utils + +import ( + "crypto/rand" + "math/big" +) + +const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// RandomString 生成指定长度的随机字符串 +func RandomString(n int) string { + b := make([]byte, n) + for i := range b { + num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + b[i] = charset[num.Int64()] + } + return string(b) +} diff --git a/internal/utils/repo.go b/internal/utils/repo.go new file mode 100644 index 0000000..557552b --- /dev/null +++ b/internal/utils/repo.go @@ -0,0 +1,68 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" +) + +// GetRepoIdentifier 返回根据仓库URL和分支生成的作者_仓库名标识符 +func GetRepoIdentifier(url string, branch string) string { + url = strings.TrimSuffix(url, ".git") + url = strings.TrimSuffix(url, "/") + + repoName := url[strings.LastIndex(url, "/")+1:] + + author := "" + lastSlash := strings.LastIndex(url, "/") + if lastSlash != -1 { + prefix := url[:lastSlash] + if strings.Contains(prefix, ":") { + parts := strings.Split(prefix, ":") + prefix = parts[len(parts)-1] + } + lastSlashPrefix := strings.LastIndex(prefix, "/") + if lastSlashPrefix != -1 { + author = prefix[lastSlashPrefix+1:] + } else { + author = prefix + } + } + + if dotIdx := strings.LastIndex(author, "."); dotIdx != -1 { + author = author[dotIdx+1:] + } + + identifier := "" + if author != "" { + identifier = author + "_" + repoName + } else { + identifier = repoName + } + + if branch != "" && branch != "master" && branch != "main" { + identifier = identifier + "_" + branch + } + + // Replace any invalid characters for tags or paths + identifier = strings.ReplaceAll(identifier, "/", "_") + identifier = strings.ReplaceAll(identifier, ".", "_") + return identifier +} + +// GetActualRepoDir 返回仓库真实的物理目录 +func GetActualRepoDir(targetPath, sourceURL, branch, sourceType string) string { + repoDir := targetPath + if sourceType == "git" && sourceURL != "" { + repoName := GetRepoIdentifier(sourceURL, branch) + // 检查 targetPath 是否已存在且是 Git 仓库 + gitDir := filepath.Join(repoDir, ".git") + if info, err := os.Stat(repoDir); err == nil && info.IsDir() { + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + // 只有当目标目录存在但不是 Git 仓库时,才追加仓库名 + repoDir = filepath.Join(repoDir, repoName) + } + } + } + return repoDir +} diff --git a/internal/utils/response.go b/internal/utils/response.go new file mode 100644 index 0000000..3e3d41b --- /dev/null +++ b/internal/utils/response.go @@ -0,0 +1,59 @@ +package utils + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 200, + Msg: "success", + Data: data, + }) +} + +func SuccessMsg(c *gin.Context, msg string) { + c.JSON(http.StatusOK, Response{ + Code: 200, + Msg: msg, + }) +} + +func Error(c *gin.Context, code int, msg string) { + c.JSON(http.StatusOK, Response{ + Code: code, + Msg: msg, + }) +} + +func BadRequest(c *gin.Context, msg string) { + Error(c, 400, msg) +} + +func Unauthorized(c *gin.Context, msg string) { + Error(c, 401, msg) +} + +func Forbidden(c *gin.Context, msg string) { + Error(c, 403, msg) +} + +func NotFound(c *gin.Context, msg string) { + Error(c, 404, msg) +} + +func TooManyRequests(c *gin.Context, msg string) { + Error(c, 429, msg) +} + +func ServerError(c *gin.Context, msg string) { + Error(c, 500, msg) +} diff --git a/internal/utils/runtime.go b/internal/utils/runtime.go new file mode 100644 index 0000000..c516e42 --- /dev/null +++ b/internal/utils/runtime.go @@ -0,0 +1,41 @@ +package utils + +import ( + "os" + "runtime" + "runtime/debug" + "strconv" + + "github.com/engigu/taskpool/internal/logger" +) + +// InitRuntime 设置运行时内存和性能优化参数 +func InitRuntime() { + // 从环境变量或配置读取 GOGC + if gogc := os.Getenv("GOGC"); gogc == "" { + // 默认设为 80,比 100 更激进一点,减少 RSS 峰值 + debug.SetGCPercent(80) + } + + // 从环境变量读取 GOMEMLIMIT (Go 1.19+) + // 建议用户在系统层面设置,例如 BH_MEM_LIMIT=256MiB + if memLimit := os.Getenv("BH_MEM_LIMIT"); memLimit != "" { + // 这里可以解析一下单位,但简单处理可以直接读取字节 + if limit, err := strconv.ParseInt(memLimit, 10, 64); err == nil { + debug.SetMemoryLimit(limit) + logger.Infof("[Runtime] 已设置内存上限: %d 字节", limit) + } + } + + // 打印当前运行时信息 + logger.Infof("[Runtime] CPU 核心数: %d, Goroutine 数量: %d", runtime.NumCPU(), runtime.NumGoroutine()) +} + +// FreeMemory 显式触发内存回收,释放物理资源给 OS +// 仅建议在执行了超大批量任务或处理了大型文件后调用 +func FreeMemory() { + // 触发 GC + runtime.GC() + // 尽可能将内存归还 OS + debug.FreeOSMemory() +} diff --git a/internal/utils/runtime_env.go b/internal/utils/runtime_env.go new file mode 100644 index 0000000..6e0358b --- /dev/null +++ b/internal/utils/runtime_env.go @@ -0,0 +1,109 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/engigu/taskpool/internal/constant" +) + +// BuildRuntimeProcessEnv 构造 TaskPool 内部可信子进程需要继承的运行时环境变量。 +// 仅包含 TaskPool 自己的路径/数据库配置,不包含用户任务环境变量。 +func BuildRuntimeProcessEnv() []string { + envs := make([]string, 0, 11) + + configPath := constant.ConfigPath + if absConfig, err := filepath.Abs(constant.ConfigPath); err == nil { + configPath = absConfig + } + envs = append(envs, formatEnvVar("BH_CONFIG_PATH", configPath)) + + if scriptsDir := ResolveAbsScriptsDir(); strings.TrimSpace(scriptsDir) != "" { + envs = append(envs, formatEnvVar("BH_SCRIPTS_DIR", scriptsDir)) + } + + appendEnvIfSet(&envs, "BH_DB_TYPE", constant.RuntimeDBType) + appendEnvIfSet(&envs, "BH_DB_HOST", constant.RuntimeDBHost) + if constant.RuntimeDBPort > 0 { + envs = append(envs, formatEnvVar("BH_DB_PORT", fmt.Sprintf("%d", constant.RuntimeDBPort))) + } + appendEnvIfSet(&envs, "BH_DB_USER", constant.RuntimeDBUser) + appendEnvIfSet(&envs, "BH_DB_PASSWORD", constant.RuntimeDBPassword) + appendEnvIfSet(&envs, "BH_DB_NAME", constant.RuntimeDBName) + appendEnvIfSet(&envs, "BH_DB_PATH", constant.RuntimeDBPath) + appendEnvIfSet(&envs, "BH_DB_DSN", constant.RuntimeDBDSN) + appendEnvIfSet(&envs, "BH_DB_TABLE_PREFIX", constant.RuntimeDBTablePrefix) + appendEnvIfSet(&envs, "BH_DB_SSL_MODE", constant.RuntimeDBSSLMode) + + return envs +} + +// GetSystemSecrets 返回当前运行时的所有系统级敏感机密(如数据库账号密码等) +func GetSystemSecrets() []string { + secrets := make([]string, 0, 8) + addIfNotEmpty := func(s string) { + if s != "" { + secrets = append(secrets, s) + } + } + + addIfNotEmpty(constant.RuntimeDBPassword) + addIfNotEmpty(constant.RuntimeDBUser) + addIfNotEmpty(constant.RuntimeDBHost) + addIfNotEmpty(constant.RuntimeDBName) + addIfNotEmpty(constant.RuntimeDBPath) + addIfNotEmpty(constant.RuntimeDBDSN) + + return secrets +} + +// BuildShellEnvPrefix 将 KEY=VALUE 环境变量切片转换为 shell 前缀。 +func BuildShellEnvPrefix(envs []string) string { + parts := make([]string, 0, len(envs)) + for _, env := range envs { + key, value, ok := strings.Cut(env, "=") + if !ok || strings.TrimSpace(key) == "" { + continue + } + parts = append(parts, ShellEnvAssignment(key, value)) + } + + if len(parts) == 0 { + return "" + } + + return strings.Join(parts, " ") + " " +} + +// ShellEnvAssignment 生成 shell 可安全使用的 KEY='VALUE' 赋值片段。 +func ShellEnvAssignment(key, value string) string { + return key + "='" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +// ResolveAbsScriptsDir 解析 TaskPool 运行时脚本目录的绝对路径。 +func ResolveAbsScriptsDir() string { + if scriptsDir := os.Getenv("BH_SCRIPTS_DIR"); scriptsDir != "" { + if filepath.IsAbs(scriptsDir) { + return filepath.Clean(scriptsDir) + } + if absScriptsDir, err := filepath.Abs(scriptsDir); err == nil { + return absScriptsDir + } + return filepath.Clean(scriptsDir) + } + + return constant.ScriptsWorkDir +} + +func appendEnvIfSet(envs *[]string, key, value string) { + if strings.TrimSpace(value) == "" { + return + } + *envs = append(*envs, formatEnvVar(key, value)) +} + +func formatEnvVar(key, value string) string { + return fmt.Sprintf("%s=%s", key, value) +} diff --git a/internal/utils/shell.go b/internal/utils/shell.go new file mode 100644 index 0000000..6666080 --- /dev/null +++ b/internal/utils/shell.go @@ -0,0 +1,101 @@ +package utils + +import ( + "os" + "os/exec" + "runtime" + "strings" + "sync" +) + +var ( + defaultShell string + defaultArgs []string + shellOnce sync.Once +) + +// GetShell 返回当前操作系统的 shell 和参数 +func GetShell() (shell string, args []string) { + shellOnce.Do(func() { + if runtime.GOOS == "windows" { + defaultShell = "cmd" + defaultArgs = []string{} + return + } + + // 1. 优先在 PATH 中查找 bash + if path, err := exec.LookPath("bash"); err == nil { + defaultShell = path + defaultArgs = []string{} + return + } + + // 2. 其次使用环境变量中的 SHELL + if envShell := os.Getenv("SHELL"); envShell != "" { + if _, err := os.Stat(envShell); err == nil { + defaultShell = envShell + defaultArgs = []string{} + return + } + } + + // 3. 尝试在 PATH 中查找 zsh 或 sh + for _, s := range []string{"zsh", "sh"} { + if path, err := exec.LookPath(s); err == nil { + defaultShell = path + defaultArgs = []string{} + return + } + } + + // 4. 最后回退到硬编码路径 + shells := []string{"/usr/bin/bash", "/bin/bash", "/usr/bin/sh", "/bin/sh"} + for _, sh := range shells { + if _, err := os.Stat(sh); err == nil { + defaultShell = sh + defaultArgs = []string{} + return + } + } + + defaultShell = "sh" + defaultArgs = []string{} + }) + + return defaultShell, defaultArgs +} + +// GetShellCommand 返回执行命令的 shell 和参数 +func GetShellCommand(command string) (shell string, args []string) { + shell, _ = GetShell() + if runtime.GOOS == "windows" { + return shell, []string{"/c", command} + } + return shell, []string{"-c", command} +} + +// NewShellCmd 创建一个交互式 shell 命令 +func NewShellCmd() *exec.Cmd { + shell, _ := GetShell() + if runtime.GOOS == "windows" { + return exec.Command(shell) + } + // Unix 系统使用 -i 启用交互模式 + return exec.Command(shell, "-i") +} + +// NewShellCommandCmd 创建一个执行指定命令的 shell 命令 +func NewShellCommandCmd(command string) *exec.Cmd { + shell, args := GetShellCommand(command) + return exec.Command(shell, args...) +} + +// QuotePath 转义并包裹路径,防止 Shell 注入 +func QuotePath(path string) string { + if path == "" { + return "''" + } + // 在 Unix-like 系统中,单引号包裹是最安全的 + // 需要将路径中的 ' 替换为 '\'' (结束当前引号,转义一个单引号,重新开启引号) + return "'" + strings.ReplaceAll(path, "'", "'\\''") + "'" +} diff --git a/internal/utils/token.go b/internal/utils/token.go new file mode 100644 index 0000000..0475cbb --- /dev/null +++ b/internal/utils/token.go @@ -0,0 +1,52 @@ +package utils + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + TokenVersion int `json:"version"` + jwt.RegisteredClaims +} + +// GenerateToken 生成 JWT token +func GenerateToken(userID string, username string, version int, expireDays int, secret string) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + TokenVersion: version, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireDays) * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +// ParseToken 解析 JWT token +func ParseToken(tokenString string, secret string) (string, string, int, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + // 校验算法 + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(secret), nil + }) + + if err != nil { + return "", "", 0, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims.UserID, claims.Username, claims.TokenVersion, nil + } + + return "", "", 0, errors.New("invalid token") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9a47ce1 --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "os" + + "github.com/engigu/taskpool/cmd" + "github.com/engigu/taskpool/internal/bootstrap" + "github.com/engigu/taskpool/internal/constant" +) + +// @title TaskPool API +// @version 1.0 +// @description TaskPool OpenAPI Server documentation. +// @termsOfService http://swagger.io/terms/ +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @host localhost:8052 +// @BasePath /open2api/v1 +// @query.collection.format multi +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and the API token. + +func printHelp() { + fmt.Println("\nTaskPool - 极致轻量、高性能的自动化任务调度平台") + fmt.Println("用法:") + fmt.Println(" taskpool <命令> [参数]") + fmt.Println("可用命令:") + for _, info := range constant.Commands { + fmt.Printf(" %-15s %s\n", info.Name, info.Description) + } + fmt.Println("\n使用 'taskpool <命令> --help' 查看具体命令的参数说明。") + fmt.Println() +} + +func main() { + if len(os.Args) < 2 { + printHelp() + os.Exit(1) + } + + commandName := os.Args[1] + + if commandName == "server" { + bootstrap.New().Run() + return + } + + if handler, ok := cmd.Handlers[commandName]; ok { + if commandName != "-v" && commandName != "-V" && commandName != "version" { + bootstrap.InitBasicForCmd() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志 + } + handler(os.Args[2:]) + return + } + + fmt.Printf("Unknown command: %s\n", commandName) + printHelp() + os.Exit(1) +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..ac7816d --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.25" diff --git a/nginx-standalone.conf b/nginx-standalone.conf new file mode 100644 index 0000000..bc0ddd7 --- /dev/null +++ b/nginx-standalone.conf @@ -0,0 +1,32 @@ +server { + listen 3000; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 1000; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy - 指向同一容器内的后端 + location /api/ { + proxy_pass http://127.0.0.1:8052; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e566184 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 1000; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy + location /api/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} \ No newline at end of file diff --git a/scripts/e2e-check.mjs b/scripts/e2e-check.mjs new file mode 100644 index 0000000..6484ba9 --- /dev/null +++ b/scripts/e2e-check.mjs @@ -0,0 +1,200 @@ +/** + * 本地后端端到端联调:通知 / SSE / 互联 + * 用法: node scripts/e2e-check.mjs [baseUrl] [user] [pass] + */ +import fs from 'node:fs' +import path from 'node:path' + +const base = process.argv[2] || 'http://127.0.0.1:8052' +const username = process.argv[3] || 'admin' +const password = process.argv[4] || 'admin123' +const cookieJar = path.join(process.cwd(), 'cookies-e2e.txt') + +const results = [] + +function log(name, ok, detail = '') { + results.push({ name, ok, detail }) + const mark = ok ? 'PASS' : 'FAIL' + console.log(`[${mark}] ${name}${detail ? ' - ' + detail : ''}`) +} + +function parseSetCookie(res) { + // undici/node fetch: getSetCookie if available + const list = + typeof res.headers.getSetCookie === 'function' + ? res.headers.getSetCookie() + : (() => { + const single = res.headers.get('set-cookie') + return single ? [single] : [] + })() + const map = new Map() + if (fs.existsSync(cookieJar)) { + for (const line of fs.readFileSync(cookieJar, 'utf8').split(/\r?\n/)) { + if (!line || line.startsWith('#')) continue + const parts = line.split('\t') + if (parts.length >= 7) map.set(parts[5], parts[6]) + } + } + for (const c of list) { + const [pair] = c.split(';') + const eq = pair.indexOf('=') + if (eq > 0) map.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()) + } + const lines = ['# Netscape HTTP Cookie File', ''] + for (const [k, v] of map) { + lines.push(`127.0.0.1\tFALSE\t/\tFALSE\t0\t${k}\t${v}`) + } + fs.writeFileSync(cookieJar, lines.join('\n')) + return [...map.entries()].map(([k, v]) => `${k}=${v}`).join('; ') +} + +function loadCookieHeader() { + if (!fs.existsSync(cookieJar)) return '' + const pairs = [] + for (const line of fs.readFileSync(cookieJar, 'utf8').split(/\r?\n/)) { + if (!line || line.startsWith('#')) continue + const parts = line.split('\t') + if (parts.length >= 7) pairs.push(`${parts[5]}=${parts[6]}`) + } + return pairs.join('; ') +} + +async function api(method, p, body) { + const headers = { Accept: 'application/json' } + const cookie = loadCookieHeader() + if (cookie) headers.Cookie = cookie + if (body !== undefined) headers['Content-Type'] = 'application/json' + const res = await fetch(`${base}${p}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + parseSetCookie(res) + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, json, headers: res.headers } +} + +async function readSSE(p, ms = 2000) { + const cookie = loadCookieHeader() + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), ms) + try { + const res = await fetch(`${base}${p}`, { + headers: { Accept: 'text/event-stream', Cookie: cookie }, + signal: ctrl.signal, + }) + const reader = res.body?.getReader() + if (!reader) return { status: res.status, text: '' } + const dec = new TextDecoder() + let text = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + text += dec.decode(value, { stream: true }) + if (text.includes('data:')) break + } + reader.cancel().catch(() => {}) + return { status: res.status, text } + } catch (e) { + return { status: 0, text: String(e?.message || e) } + } finally { + clearTimeout(timer) + } +} + +async function main() { + console.log(`E2E base=${base}`) + + // login + const login = await api('POST', '/api/v1/auth/login', { username, password }) + log('auth.login', login.status === 200 && login.json?.code === 200, JSON.stringify(login.json?.msg || login.json)) + + const me = await api('GET', '/api/v1/auth/me') + log('auth.me', me.status === 200 && me.json?.code === 200, JSON.stringify(me.json?.data || me.json?.msg)) + + // notify + const types = await api('GET', '/api/v1/notify/types') + const typeList = types.json?.data?.channel_types || [] + log('notify.types', types.status === 200 && typeList.length > 0, `count=${typeList.length}`) + + const channelBody = { + name: 'e2e-custom', + type: 'Custom', + enabled: true, + config: { + webhook: 'http://127.0.0.1:9/hook', + body: '{"text":"TEXT"}', + }, + } + const saveCh = await api('POST', '/api/v1/notify/channels', channelBody) + log('notify.saveChannel', saveCh.status === 200 && saveCh.json?.code === 200, JSON.stringify(saveCh.json?.msg || saveCh.json)) + + const channels = await api('GET', '/api/v1/notify/channels') + const chList = Array.isArray(channels.json?.data) ? channels.json.data : [] + log('notify.listChannels', channels.status === 200 && channels.json?.code === 200, `count=${chList.length}`) + + const testCh = await api('POST', '/api/v1/notify/channels/test', channelBody) + // 预期连接失败但接口应返回 200 + result 结构 + log( + 'notify.testChannel', + testCh.status === 200 && testCh.json?.code === 200, + JSON.stringify(testCh.json?.data || testCh.json?.msg), + ) + + const settingsNotify = await api('GET', '/api/v1/settings/notify') + const token = settingsNotify.json?.data?.token || settingsNotify.json?.data?.notify_token || '' + log('settings.notify', settingsNotify.status === 200 && settingsNotify.json?.code === 200, token ? `token=${token.slice(0, 8)}...` : 'no token') + + // interconnect + const nodeBody = { + name: `e2e-node-${Date.now()}`, + url: 'http://127.0.0.1:18052', + token: 'test-token', + remark: 'e2e', + } + const createNode = await api('POST', '/api/v1/interconnect/nodes', nodeBody) + const nodeId = createNode.json?.data?.id + log('interconnect.createNode', createNode.status === 200 && !!nodeId, nodeId || JSON.stringify(createNode.json)) + + const nodes = await api('GET', '/api/v1/interconnect/nodes') + const nodeList = Array.isArray(nodes.json?.data) ? nodes.json.data : [] + log('interconnect.listNodes', nodes.status === 200 && nodeList.length > 0, `count=${nodeList.length}`) + + const child = await api('GET', '/api/v1/interconnect/child/status') + log('interconnect.childStatus', child.status === 200 && child.json?.code === 200, JSON.stringify(child.json?.data)) + + if (nodeId) { + const st = await api('GET', `/api/v1/interconnect/nodes/${nodeId}/status`) + log('interconnect.nodeStatus', st.status === 200, JSON.stringify(st.json?.data || st.json?.msg)) + } + + // monitor SSE + const mon = await readSSE('/api/v1/monitor/sse', 2500) + log('monitor.sse', mon.text.includes('data:') || mon.text.includes('cpu') || mon.text.includes('host'), mon.text.slice(0, 120).replace(/\s+/g, ' ')) + + // logs SSE param validation + const bad = await api('GET', '/api/v1/logs/sse?id=test') + log('logs.sse.paramGuard', bad.status === 400 || bad.json?.error || bad.json?.msg, JSON.stringify(bad.json)) + + // stats + const stats = await api('GET', '/api/v1/stats') + log('dashboard.stats', stats.status === 200 && stats.json?.code === 200, JSON.stringify(stats.json?.data)) + + const failed = results.filter((r) => !r.ok) + console.log('\n==== summary ====') + console.log(`total=${results.length} pass=${results.length - failed.length} fail=${failed.length}`) + if (failed.length) { + process.exitCode = 1 + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/scripts/migrate-db-brand.go b/scripts/migrate-db-brand.go new file mode 100644 index 0000000..0c78157 --- /dev/null +++ b/scripts/migrate-db-brand.go @@ -0,0 +1,266 @@ +// 将本地 sqlite 库从 baihu 前缀迁移为 taskpool 前缀,并更新触发类型/站点标题。 +// +// 用法(项目根目录): +// +// go run ./scripts/migrate-db-brand.go +// go run ./scripts/migrate-db-brand.go -db data/baihu.db -from baihu_ -to taskpool_ +package main + +import ( + "database/sql" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/glebarez/sqlite" +) + +func main() { + dbPath := flag.String("db", "data/baihu.db", "sqlite database path") + fromPrefix := flag.String("from", "baihu_", "old table prefix") + toPrefix := flag.String("to", "taskpool_", "new table prefix") + outPath := flag.String("out", "data/taskpool.db", "output database path (copy then migrate)") + dryRun := flag.Bool("dry-run", false, "only print actions") + flag.Parse() + + if err := run(*dbPath, *outPath, *fromPrefix, *toPrefix, *dryRun); err != nil { + fmt.Fprintf(os.Stderr, "migrate failed: %v\n", err) + os.Exit(1) + } +} + +func run(srcDB, outDB, fromPrefix, toPrefix string, dryRun bool) error { + if _, err := os.Stat(srcDB); err != nil { + return fmt.Errorf("source db not found: %s", srcDB) + } + + // 先 checkpoint wal,确保主库完整 + if err := checkpoint(srcDB); err != nil { + fmt.Printf("warn: checkpoint: %v\n", err) + } + + absSrc, _ := filepath.Abs(srcDB) + absOut, _ := filepath.Abs(outDB) + fmt.Printf("source: %s\noutput: %s\nprefix: %s -> %s\n", absSrc, absOut, fromPrefix, toPrefix) + + if dryRun { + db, err := sql.Open("sqlite", srcDB) + if err != nil { + return err + } + defer db.Close() + tables, err := listTables(db, fromPrefix) + if err != nil { + return err + } + for _, t := range tables { + fmt.Printf("would rename %s -> %s\n", t, strings.Replace(t, fromPrefix, toPrefix, 1)) + } + return nil + } + + if err := os.MkdirAll(filepath.Dir(outDB), 0o755); err != nil { + return err + } + + // 备份源库 + bak := srcDB + ".bak-" + time.Now().Format("20060102-150405") + if err := copyFile(srcDB, bak); err != nil { + return fmt.Errorf("backup failed: %w", err) + } + fmt.Printf("backup: %s\n", bak) + + // 复制到目标(若目标就是源,则原地改) + same := filepath.Clean(absSrc) == filepath.Clean(absOut) + work := outDB + if !same { + // 清理目标旁路文件 + _ = os.Remove(outDB) + _ = os.Remove(outDB + "-wal") + _ = os.Remove(outDB + "-shm") + if err := copyFile(srcDB, outDB); err != nil { + return fmt.Errorf("copy to output failed: %w", err) + } + // 若源有 wal,已 checkpoint,通常足够 + } else { + work = srcDB + } + + db, err := sql.Open("sqlite", work) + if err != nil { + return err + } + defer db.Close() + + if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + return err + } + + tables, err := listTables(db, fromPrefix) + if err != nil { + return err + } + if len(tables) == 0 { + // 也许已经迁过 + tables2, _ := listTables(db, toPrefix) + if len(tables2) > 0 { + fmt.Println("tables already use new prefix; continue data fixes") + } else { + return fmt.Errorf("no tables with prefix %q found", fromPrefix) + } + } + + tx, err := db.Begin() + if err != nil { + return err + } + + for _, oldName := range tables { + newName := strings.Replace(oldName, fromPrefix, toPrefix, 1) + if newName == oldName { + continue + } + // 若新表已存在则跳过 + var exists int + _ = tx.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?`, newName).Scan(&exists) + if exists > 0 { + fmt.Printf("skip (exists): %s\n", newName) + continue + } + sqlStmt := fmt.Sprintf(`ALTER TABLE "%s" RENAME TO "%s"`, oldName, newName) + if _, err := tx.Exec(sqlStmt); err != nil { + _ = tx.Rollback() + return fmt.Errorf("rename %s -> %s: %w", oldName, newName, err) + } + fmt.Printf("renamed: %s -> %s\n", oldName, newName) + } + + // 索引名里若带旧前缀,SQLite 一般会随表走;对显式旧索引再尝试重命名 + indexes, err := listIndexes(tx, fromPrefix) + if err != nil { + _ = tx.Rollback() + return err + } + for _, oldIdx := range indexes { + newIdx := strings.Replace(oldIdx, fromPrefix, toPrefix, 1) + if newIdx == oldIdx { + continue + } + var exists int + _ = tx.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='index' AND name=?`, newIdx).Scan(&exists) + if exists > 0 { + continue + } + // SQLite 3.26+ supports ALTER INDEX RENAME; if fails, ignore + stmt := fmt.Sprintf(`ALTER INDEX "%s" RENAME TO "%s"`, oldIdx, newIdx) + if _, err := tx.Exec(stmt); err != nil { + fmt.Printf("warn: index rename %s: %v\n", oldIdx, err) + } else { + fmt.Printf("renamed index: %s -> %s\n", oldIdx, newIdx) + } + } + + settingsTable := toPrefix + "settings" + tasksTable := toPrefix + "tasks" + + // 站点标题 + if hasTable(tx, settingsTable) { + res, err := tx.Exec(`UPDATE "`+settingsTable+`" SET value = ? WHERE section = 'site' AND key = 'title' AND (value = '白虎面板' OR value = 'Baihu Panel' OR value LIKE '%白虎%')`, "任务池") + if err != nil { + fmt.Printf("warn: update site title: %v\n", err) + } else if n, _ := res.RowsAffected(); n > 0 { + fmt.Printf("updated site title rows: %d\n", n) + } + } + + // 触发类型 + if hasTable(tx, tasksTable) { + res, err := tx.Exec(`UPDATE "`+tasksTable+`" SET trigger_type = 'taskpool_startup' WHERE trigger_type = 'baihu_startup'`) + if err != nil { + fmt.Printf("warn: update trigger_type: %v\n", err) + } else if n, _ := res.RowsAffected(); n > 0 { + fmt.Printf("updated trigger_type rows: %d\n", n) + } + } + + if err := tx.Commit(); err != nil { + return err + } + + if _, err := db.Exec(`VACUUM`); err != nil { + fmt.Printf("warn: vacuum: %v\n", err) + } + if _, err := db.Exec(`PRAGMA wal_checkpoint(FULL)`); err != nil { + fmt.Printf("warn: final checkpoint: %v\n", err) + } + + fmt.Println("migrate done") + fmt.Println("next:") + fmt.Println(" 1) update configs/config.ini:") + fmt.Println(" path = data/taskpool.db") + fmt.Println(" table_prefix = taskpool_") + fmt.Println(" dbname = taskpool") + fmt.Println(" 2) restart: bin\\taskpool.exe server") + return nil +} + +func checkpoint(dbPath string) error { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return err + } + defer db.Close() + _, err = db.Exec(`PRAGMA wal_checkpoint(FULL)`) + return err +} + +func listTables(db *sql.DB, prefix string) ([]string, error) { + rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? AND name NOT LIKE 'sqlite_%' ORDER BY name`, prefix+"%") + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +func listIndexes(tx *sql.Tx, prefix string) ([]string, error) { + rows, err := tx.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name LIKE ? AND name NOT LIKE 'sqlite_%' ORDER BY name`, prefix+"%") + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +func hasTable(tx *sql.Tx, name string) bool { + var n int + _ = tx.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n) + return n > 0 +} + +func copyFile(src, dst string) error { + in, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, in, 0o644) +} diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..7416f6d --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,21 @@ +[supervisord] +nodaemon=true +logfile=/app/logs/supervisord.log +pidfile=/var/run/supervisord.pid +user=root + +[program:backend] +command=/app/taskpool +directory=/app +autostart=true +autorestart=true +stderr_logfile=/app/logs/backend.err.log +stdout_logfile=/app/logs/backend.out.log +environment=BH_SERVER_PORT="8052",BH_SERVER_HOST="0.0.0.0" + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stderr_logfile=/app/logs/nginx.err.log +stdout_logfile=/app/logs/nginx.out.log diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3c3cb39 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +# Logs +# logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..987dee9 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2629 @@ +{ + "name": "taskpool-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskpool-web", + "version": "1.0.0", + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.80.0", + "@tanstack/react-router": "^1.120.0", + "@tanstack/react-table": "^8.21.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "framer-motion": "^11.15.0", + "lucide-react": "^0.525.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "recharts": "^2.15.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^22.0.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.4.0", + "postcss": "^8.5.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.9.0", + "vite": "^8.1.5" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.18.tgz", + "integrity": "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.15", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.15", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.15.tgz", + "integrity": "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/isbot": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", + "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.4.8", + "marked": "14.0.0" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a55159f --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "taskpool-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.80.0", + "@tanstack/react-router": "^1.120.0", + "@tanstack/react-table": "^8.21.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "framer-motion": "^11.15.0", + "lucide-react": "^0.525.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "recharts": "^2.15.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^22.0.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.4.0", + "postcss": "^8.5.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.9.0", + "vite": "^8.1.5" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..a617215 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1277 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-query': + specifier: ^5.80.0 + version: 5.101.2(react@19.2.7) + '@tanstack/react-router': + specifier: ^1.120.0 + version: 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-table': + specifier: ^8.21.0 + version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 + framer-motion: + specifier: ^11.15.0 + version: 11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + lucide-react: + specifier: ^0.525.0 + version: 0.525.0(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + recharts: + specifier: ^2.15.0 + version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + zustand: + specifier: ^5.0.0 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/react': + specifier: ^19.1.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.5(@types/node@22.20.1)) + autoprefixer: + specifier: ^10.4.0 + version: 10.5.4(postcss@8.5.19) + postcss: + specifier: ^8.5.0 + version: 8.5.19 + tailwindcss: + specifier: ^4.1.0 + version: 4.3.3 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vite: + specifier: ^8.1.5 + version: 8.1.5(@types/node@22.20.1) + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.18': + resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/router-core@1.171.15': + resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + + autoprefixer@10.5.4: + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-equals@5.4.1: + resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==} + engines: {node: '>=6.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@11.18.2: + resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lucide-react@0.525.0: + resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + motion-dom@11.18.1: + resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} + + motion-utils@11.18.1: + resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + seroval-plugins@1.5.5: + resolution: {integrity: sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.5: + resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tanstack/history@1.162.0': {} + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.15 + isbot: 5.2.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/router-core@1.171.15': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.5 + seroval-plugins: 1.5.5(seroval@1.5.5) + + '@tanstack/store@0.9.3': {} + + '@tanstack/table-core@8.21.3': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@22.20.1) + + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + + autoprefixer@10.5.4(postcss@8.5.19): + dependencies: + browserslist: 4.28.6 + caniuse-lite: 1.0.30001806 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.19 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.43: {} + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + caniuse-lite@1.0.30001806: {} + + clsx@2.1.1: {} + + cookie-es@3.1.1: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + decimal.js-light@2.5.1: {} + + detect-libc@2.1.2: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + electron-to-chromium@1.5.393: {} + + escalade@3.2.0: {} + + eventemitter3@4.0.7: {} + + fast-equals@5.4.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fraction.js@5.3.4: {} + + framer-motion@11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 11.18.1 + motion-utils: 11.18.1 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + fsevents@2.3.3: + optional: true + + internmap@2.0.3: {} + + isbot@5.2.1: {} + + js-tokens@4.0.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lucide-react@0.525.0(react@19.2.7): + dependencies: + react: 19.2.7 + + marked@14.0.0: {} + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + motion-dom@11.18.1: + dependencies: + motion-utils: 11.18.1 + + motion-utils@11.18.1: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + object-assign@4.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-smooth@4.0.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + fast-equals: 5.4.1 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-transition-group: 4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + scheduler@0.27.0: {} + + seroval-plugins@1.5.5(seroval@1.5.5): + dependencies: + seroval: 1.5.5 + + seroval@1.5.5: {} + + source-map-js@1.2.1: {} + + state-local@1.0.7: {} + + tailwindcss@4.3.3: {} + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@8.1.5(@types/node@22.20.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..cd2feac --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + T + \ No newline at end of file diff --git a/web/public/logo.svg b/web/public/logo.svg new file mode 100644 index 0000000..e9dac84 --- /dev/null +++ b/web/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..60fac1b --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,24 @@ +{ + "name": "任务池", + "short_name": "TaskPool", + "description": "轻量高性能自动化任务调度平台", + "start_url": "/", + "display": "standalone", + "background_color": "#0d0d0d", + "theme_color": "#0d0d0d", + "lang": "zh-CN", + "icons": [ + { + "src": "/logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/web/public/workers/event-worker.js b/web/public/workers/event-worker.js new file mode 100644 index 0000000..b2d5479 --- /dev/null +++ b/web/public/workers/event-worker.js @@ -0,0 +1,109 @@ +/** + * TaskPool System Event SharedWorker + * + * 这个 Worker 维护全浏览器唯一的一个 WebSocket 连接, + * 并通过 postMessage 将收到的事件同步给所有打开的标签页。 + */ + +let ws = null; +const ports = new Set(); +let reconnectTimer = null; +let lockReconnect = false; + +// WebSocket 配置 (由第一个连接的页面通过消息传过来,或者使用默认值) +let wsUrl = ''; + +/** + * 建立 WebSocket 连接 + */ +function connect() { + if (!wsUrl || ws) return; + + try { + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log('[SharedWorker] WebSocket 已连接'); + lockReconnect = false; + }; + + ws.onmessage = (event) => { + // 广播消息给所有连接的端口 + broadcast(event.data); + }; + + ws.onclose = () => { + console.log('[SharedWorker] WebSocket 已断开'); + ws = null; + reconnect(); + }; + + ws.onerror = (err) => { + console.error('[SharedWorker] WebSocket 错误:', err); + ws = null; + reconnect(); + }; + } catch (e) { + console.error('[SharedWorker] 建立连接失败:', e); + reconnect(); + } +} + +/** + * 重连逻辑 + */ +function reconnect() { + if (lockReconnect) return; + lockReconnect = true; + + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + connect(); + lockReconnect = false; + }, 5000); // 5秒后尝试重连 +} + +/** + * 广播消息给所有活跃标签页 + */ +function broadcast(data) { + const msg = typeof data === 'string' ? JSON.parse(data) : data; + ports.forEach(port => { + try { + port.postMessage(msg); + } catch (e) { + // 如果端口失效,移除它 + ports.delete(port); + } + }); +} + +/** + * 处理 SharedWorker 连接 + */ +self.onconnect = (e) => { + const port = e.ports[0]; + ports.add(port); + + port.onmessage = (event) => { + const { type, data } = event.data; + + switch (type) { + case 'init': + // 初始化配置 + if (data.url) { + wsUrl = data.url; + if (!ws) connect(); + } + break; + case 'ping': + port.postMessage({ type: 'pong' }); + break; + } + }; + + port.start(); + + // 发送一条初始消息确认连接成功 + port.postMessage({ type: 'worker_ready' }); +}; diff --git a/web/scripts/fetch-ref.mjs b/web/scripts/fetch-ref.mjs new file mode 100644 index 0000000..fe297b7 --- /dev/null +++ b/web/scripts/fetch-ref.mjs @@ -0,0 +1,44 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +const root = new URL('../_ref/', import.meta.url) +await mkdir(root, { recursive: true }) + +const files = [ + ['tasks-Tasks.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/Tasks.vue'], + ['tasks-TaskDialog.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/TaskDialog.vue'], + ['tasks-RepoDialog.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/RepoDialog.vue'], + ['tasks-TaskAdvancedConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskAdvancedConfig.vue'], + ['tasks-TaskCronConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskCronConfig.vue'], + ['tasks-TaskLangConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskLangConfig.vue'], + ['tasks-TaskNotificationConfig.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/tasks/components/TaskNotificationConfig.vue'], + ['history-History.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/history/History.vue'], + ['agents-Agents.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/agents/Agents.vue'], + ['editor-Editor.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/editor/Editor.vue'], + ['env-Environments.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/environments/Environments.vue'], + ['notify-Notify.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/notify/Notify.vue'], + ['settings-Settings.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/settings/Settings.vue'], + ['dashboard-Dashboard.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/dashboard/Dashboard.vue'], + ['monitor-Monitor.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/monitor/Monitor.vue'], + ['languages-Languages.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/languages/Languages.vue'], + ['deps-Dependencies.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/dependencies/Dependencies.vue'], + ['interconnect-index.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/interconnect/index.vue'], + ['logs-MessageLogs.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/logs/MessageLogs.vue'], + ['terminal-Terminal.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/views/terminal/Terminal.vue'], + ['MainLayout.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/layouts/MainLayout.vue'], + ['LogDetailCard.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/components/LogDetailCard.vue'], + ['NodeSwitcher.vue', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/components/NodeSwitcher.vue'], + ['api-index.ts', 'https://raw.githubusercontent.com/engigu/taskpool/main/web/src/api/index.ts'], +] + +for (const [name, url] of files) { + try { + const res = await fetch(url) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + const text = await res.text() + await writeFile(new URL(name, root), text, 'utf8') + console.log('OK', name, text.length) + } catch (e) { + console.error('FAIL', name, e.message) + } +} diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts new file mode 100644 index 0000000..a548ee5 --- /dev/null +++ b/web/src/api/hooks.ts @@ -0,0 +1,349 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from './index' +import * as interconnect from './interconnect' + +export function useUser(enabled = true) { + return useQuery({ + queryKey: ['user'], + queryFn: () => api.auth.me(), + retry: false, + staleTime: 5 * 60 * 1000, + enabled, + }) +} + +export function useLogin() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: { username: string; password: string }) => api.auth.login(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['user'] }) + }, + }) +} + +export function useLogout() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.auth.logout(), + onSuccess: () => { + qc.clear() + }, + }) +} + +export function useStats() { + return useQuery({ + queryKey: ['stats'], + queryFn: () => api.dashboard.stats(), + refetchInterval: 10000, + }) +} + +export function useSentence() { + return useQuery({ + queryKey: ['sentence'], + queryFn: () => api.dashboard.sentence(), + staleTime: 24 * 60 * 60 * 1000, + }) +} + +export function useSendStats(days?: number) { + return useQuery({ + queryKey: ['sendStats', days], + queryFn: async () => { + const data = await api.dashboard.sendStats(days) + return Array.isArray(data) ? data : [] + }, + }) +} + +export function useTaskStats(days?: number) { + return useQuery({ + queryKey: ['taskStats', days], + queryFn: async () => { + const data = await api.dashboard.taskStats(days) + return Array.isArray(data) ? data : [] + }, + }) +} + +export function useMonitorStats() { + return useQuery({ + queryKey: ['monitor'], + queryFn: () => api.settings.getMonitor(), + refetchInterval: 5000, + }) +} + +export function useTasks(params?: Parameters[0]) { + return useQuery({ + queryKey: ['tasks', params], + queryFn: async () => { + const data = await api.tasks.list(params) + return { + ...data, + data: Array.isArray(data?.data) ? data.data : [], + total: data?.total ?? 0, + } + }, + }) +} + +export function useTaskTags() { + return useQuery({ + queryKey: ['taskTags'], + queryFn: () => api.tasks.tags(), + }) +} + +export function useCreateTask() { + const qc = useQueryClient() + return useMutation({ + mutationFn: api.tasks.create, + onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), + }) +} + +export function useUpdateTask() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.tasks.update(id, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), + }) +} + +export function useDeleteTask() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.tasks.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), + }) +} + +export function useExecuteTask() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.tasks.execute(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tasks'] }) + qc.invalidateQueries({ queryKey: ['logs'] }) + qc.invalidateQueries({ queryKey: ['stats'] }) + }, + }) +} + +export function useLogs(params?: Parameters[0]) { + return useQuery({ + queryKey: ['logs', params], + queryFn: () => api.logs.list(params), + refetchInterval: 5000, + }) +} + +export function useLogDetail(id: string) { + return useQuery({ + queryKey: ['log', id], + queryFn: () => api.logs.get(id), + enabled: !!id, + }) +} + +export function useEnvs(params?: Parameters[0]) { + return useQuery({ + queryKey: ['envs', params], + queryFn: () => api.env.list(params), + }) +} + +export function useEnvTags() { + return useQuery({ + queryKey: ['envTags'], + queryFn: () => api.env.tags(), + }) +} + +export function useCreateEnv() { + const qc = useQueryClient() + return useMutation({ + mutationFn: api.env.create, + onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }), + }) +} + +export function useUpdateEnv() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.env.update(id, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }), + }) +} + +export function useDeleteEnv() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.env.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }), + }) +} + +export function useFileTree() { + return useQuery({ + queryKey: ['fileTree'], + queryFn: () => api.files.tree(), + }) +} + +export function useAgents() { + return useQuery({ + queryKey: ['agents'], + queryFn: () => api.agents.list(), + refetchInterval: 10000, + }) +} + +export function useAgentTokens() { + return useQuery({ + queryKey: ['agentTokens'], + queryFn: () => api.agents.listTokens(), + }) +} + +export function useMiseList() { + return useQuery({ + queryKey: ['mise'], + queryFn: () => api.mise.list(), + }) +} + +export function useDeps(params?: Parameters[0]) { + return useQuery({ + queryKey: ['deps', params], + queryFn: () => api.deps.list(params), + }) +} + +export function useNotifyChannels() { + return useQuery({ + queryKey: ['notifyChannels'], + queryFn: () => api.notify.getChannels(), + }) +} + +export function useNotifyBindings() { + return useQuery({ + queryKey: ['notifyBindings'], + queryFn: () => api.notify.getBindings(), + }) +} + +export function useNotifyTypes() { + return useQuery({ + queryKey: ['notifyTypes'], + queryFn: () => api.notify.getTypes(), + }) +} + +export function useAppLogs(params?: Parameters[0]) { + return useQuery({ + queryKey: ['appLogs', params], + queryFn: async () => { + const data = await api.appLogs.list(params) + return { + ...data, + data: Array.isArray(data?.data) ? data.data : [], + total: data?.total ?? 0, + } + }, + }) +} + +export function useSiteSettings() { + return useQuery({ + queryKey: ['siteSettings'], + queryFn: () => api.settings.getSite(), + }) +} + +export function usePublicSite() { + return useQuery({ + queryKey: ['publicSite'], + queryFn: () => api.settings.getPublicSite(), + staleTime: 5 * 60 * 1000, + retry: false, + refetchOnWindowFocus: false, + }) +} + +export function useSchedulerSettings() { + return useQuery({ + queryKey: ['schedulerSettings'], + queryFn: () => api.settings.getScheduler(), + }) +} + +export function useAbout() { + return useQuery({ + queryKey: ['about'], + queryFn: () => api.settings.getAbout(), + }) +} + +export function useNodes() { + return useQuery({ + queryKey: ['nodes'], + queryFn: () => interconnect.getNodes(), + refetchInterval: 15000, + }) +} + +export function useCreateNode() { + const qc = useQueryClient() + return useMutation({ + mutationFn: interconnect.createNode, + onSuccess: () => qc.invalidateQueries({ queryKey: ['nodes'] }), + }) +} + +export function useDeleteNode() { + const qc = useQueryClient() + return useMutation({ + mutationFn: interconnect.deleteNode, + onSuccess: () => qc.invalidateQueries({ queryKey: ['nodes'] }), + }) +} + +export function useTerminalCmds() { + return useQuery({ + queryKey: ['terminalCmds'], + queryFn: () => api.terminal.cmds(), + }) +} + +export function useInstallStatus() { + return useQuery({ + queryKey: ['installStatus'], + queryFn: async () => { + const res = await fetch('/api/v1/install/status', { credentials: 'include' }) + if (!res.ok) throw new Error('获取安装状态失败') + return res.json() as Promise<{ installed: boolean; has_admin?: boolean; message?: string }> + }, + retry: false, + }) +} + +export function useInstall() { + return useMutation({ + mutationFn: async (data: Record) => { + const res = await fetch('/api/v1/install', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || json.msg || '安装失败') + return json + }, + }) +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..f100b69 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,872 @@ +// 获取 base URL(从后端注入的全局变量) +const BASE_URL = (window as any).__BASE_URL__ || '' +const API_VERSION = (window as any).__API_VERSION__ || '/api/v1' +const API_BASE_URL = BASE_URL + API_VERSION + +interface ApiResponse { + code: number + msg: string + data: T +} + +export interface MonitorStats { + env: { + os: string + arch: string + go_version: string + num_cpu: number + goroutines: number + } + host: { + cpu_percent: number + mem_total: number + mem_used: number + mem_percent: number + disk_total: number + disk_used: number + disk_percent: number + uptime: number + platform: string + } + mem: { + alloc: number + total_alloc: number + sys: number + lookups: number + mallocs: number + frees: number + } + heap: { + heap_alloc: number + heap_sys: number + heap_idle: number + heap_inuse: number + heap_released: number + heap_objects: number + } + gc: { + next_gc: number + last_gc: number + pause_total_ns: number + num_gc: number + } + scheduler: { + scheduled: number + running: number + queue_size: number + worker_count: number + workers: { + id: number + status: string + task_id?: string + task_name?: string + start_time?: number + duration?: number + }[] + } +} +export let activeInterconnectNodeId = localStorage.getItem('activeInterconnectNodeId') || '' +export let activeInterconnectNodeName = localStorage.getItem('activeInterconnectNodeName') || '' + +export function setActiveInterconnectNodeId(id: string, name?: string) { + activeInterconnectNodeId = id + if (name !== undefined) { + activeInterconnectNodeName = name + } + localStorage.removeItem('site_settings_cache') + if (id) { + localStorage.setItem('activeInterconnectNodeId', id) + if (name) localStorage.setItem('activeInterconnectNodeName', name) + document.cookie = `active_interconnect_node_id=${id}; path=/; max-age=${7 * 24 * 3600}` + } else { + localStorage.removeItem('activeInterconnectNodeId') + localStorage.removeItem('activeInterconnectNodeName') + activeInterconnectNodeName = '' + document.cookie = `active_interconnect_node_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC` + } +} + +let redirectingToLogin = false + +function redirectToLogin() { + const loginPath = `${BASE_URL}/login` + if (redirectingToLogin) return + if (window.location.pathname === `${BASE_URL}/login` || window.location.pathname.endsWith('/login')) return + redirectingToLogin = true + window.location.href = loginPath +} + +async function parseApiResponse(res: Response, url: string): Promise> { + const text = await res.text() + const trimmed = text.trim() + + if (!trimmed) { + if (res.status === 502 || res.status === 503 || res.status === 504) { + throw new Error('后端服务不可用,请确认已启动 TaskPool server(默认 :8052)') + } + if (!res.ok) { + throw new Error(`请求失败(HTTP ${res.status})`) + } + throw new Error('服务器返回了空响应') + } + + let json: ApiResponse + try { + json = JSON.parse(trimmed) as ApiResponse + } catch { + if (res.status === 502 || res.status === 503 || res.status === 504) { + throw new Error('后端服务不可用,请确认已启动 TaskPool server(默认 :8052)') + } + throw new Error(`服务器返回了非 JSON 响应(HTTP ${res.status})`) + } + + if (json.code === 401) { + // 公开接口不应触发硬跳转;其它接口未登录时跳转登录页 + const isPublic = + url.startsWith('/settings/public') || url.startsWith('/auth/login') || url.startsWith('/install') + if (!isPublic) redirectToLogin() + throw new Error(json.msg || '请先登录') + } + + if (typeof json.code === 'number' && json.code !== 200) { + throw new Error(json.msg || '请求失败') + } + + // 兼容非标准成功体:HTTP 非 2xx 时优先报状态码 + if (!res.ok && (json as any)?.code === undefined) { + throw new Error(json.msg || `请求失败(HTTP ${res.status})`) + } + + return json +} + +export async function request(url: string, options?: RequestInit): Promise { + let res: Response + try { + res = await fetch(`${API_BASE_URL}${url}`, { + ...options, + credentials: 'include', // 携带 Cookie + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + } catch (err: any) { + // 路由切换 / React Query 取消请求时,浏览器会报 net::ERR_ABORTED + if (err?.name === 'AbortError' || options?.signal?.aborted) { + throw err + } + throw new Error(err?.message || '网络请求失败,请检查后端是否已启动') + } + + const json = await parseApiResponse(res, url) + return json.data +} + +// 检查登录状态(不触发自动跳转) +export async function checkAuth(): Promise { + try { + const res = await fetch(`${API_BASE_URL}/auth/me`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }) + const text = await res.text() + if (!text.trim()) return false + const json = JSON.parse(text) as ApiResponse<{ username: string }> + return json.code === 200 + } catch { + return false + } +} + +export const api = { + auth: { + login: (data: { username: string; password: string }) => + request<{ user: string }>('/auth/login', { method: 'POST', body: JSON.stringify(data) }), + logout: () => request('/auth/logout', { method: 'POST' }), + me: () => request<{ username: string; role: string }>('/auth/me'), + register: (data: { username: string; password: string; email: string }) => + request('/auth/register', { method: 'POST', body: JSON.stringify(data) }) + }, + tasks: { + list: (params?: { page?: number; page_size?: number; name?: string; agent_id?: string; tags?: string; type?: string; sort_by?: string; order?: string }) => { + const query = new URLSearchParams() + if (params?.page) query.set('page', String(params.page)) + if (params?.page_size) query.set('page_size', String(params.page_size)) + if (params?.name) query.set('name', params.name) + if (params?.tags) query.set('tags', params.tags) + if (params?.agent_id) query.set('agent_id', params.agent_id) + if (params?.type) query.set('type', params.type) + if (params?.sort_by) query.set('sort_by', params.sort_by) + if (params?.order) query.set('order', params.order) + return request(`/tasks?${query}`) + }, + create: (data: Partial) => request('/tasks', { method: 'POST', body: JSON.stringify(data) }), + update: (id: string, data: Partial) => request(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id: string, params?: { delete_files?: boolean }) => { + const query = new URLSearchParams() + if (params?.delete_files !== undefined) query.set('delete_files', String(params.delete_files)) + const queryString = query.toString() + return request(`/tasks/${id}${queryString ? '?' + queryString : ''}`, { method: 'DELETE' }) + }, + batchDelete: (ids: string[]) => request<{ count: number }>('/tasks/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) }), + batchDeleteByQuery: (params?: { name?: string, agent_id?: string, tags?: string, type?: string }) => { + const query = new URLSearchParams() + if (params?.name) query.append('name', params.name) + if (params?.agent_id) query.append('agent_id', params.agent_id) + if (params?.tags) query.append('tags', params.tags) + if (params?.type && params.type !== 'all') query.append('type', params.type) + return request<{ count: number }>(`/tasks/batch-by-query?${query.toString()}`, { method: 'DELETE' }) + }, + execute: (id: string) => request(`/execute/task/${id}`, { method: 'POST' }), + stop: (logID: string) => request(`/tasks/stop/${logID}`, { method: 'POST' }), + tags: () => request('/tasks/tags') + }, + scripts: { + list: () => request('/scripts'), + create: (data: Partial