22 Commits

Author SHA1 Message Date
c9639ae04e backend: unload local embedding model when idle
All checks were successful
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / build-and-push (admin) (push) Successful in 3s
docker-images / build-and-push (backend) (push) Successful in 16s
docker-images / build-and-push (frontend) (push) Successful in 3s
docker-images / submit-indexnow (push) Has been skipped
2026-04-16 13:35:14 +08:00
7d4f027062 Update frontend test favicon
All checks were successful
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / build-and-push (admin) (push) Successful in 2s
docker-images / build-and-push (backend) (push) Successful in 2s
docker-images / build-and-push (frontend) (push) Successful in 56s
docker-images / submit-indexnow (push) Successful in 15s
2026-04-05 17:07:55 +08:00
646a32f207 Ignore local artifacts and wrap worker job text
All checks were successful
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 35s
docker-images / build-and-push (backend) (push) Successful in 2s
docker-images / build-and-push (frontend) (push) Successful in 2s
docker-images / submit-indexnow (push) Has been skipped
Ignore local artifacts and wrap worker job text
2026-04-03 20:26:36 +00:00
381dc9b854 Fix web push delivery handling and worker console
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
2026-04-04 04:15:20 +08:00
ab18bbaf23 Format backend controller responses
All checks were successful
docker-images / resolve-build-targets (push) Successful in 4s
docker-images / build-and-push (admin) (push) Successful in 3s
docker-images / build-and-push (backend) (push) Successful in 14m20s
docker-images / build-and-push (frontend) (push) Successful in 7s
docker-images / submit-indexnow (push) Has been skipped
2026-04-04 00:45:47 +08:00
d065e3da88 Show AI reindex progress in admin
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (admin) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
ui-regression / playwright-regression (push) Failing after 8m14s
2026-04-04 00:42:23 +08:00
11ec00281c Fix AI reindex job execution and progress
Some checks failed
docker-images / resolve-build-targets (push) Failing after 1s
docker-images / build-and-push (admin) (push) Has been cancelled
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
ui-regression / playwright-regression (push) Has been cancelled
2026-04-04 00:40:46 +08:00
320595ee1c Unify homepage panels and subscription actions
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
ui-regression / playwright-regression (push) Failing after 11m59s
docker-images / build-and-push (admin) (push) Successful in 3s
docker-images / build-and-push (backend) (push) Successful in 3s
docker-images / build-and-push (frontend) (push) Successful in 58s
docker-images / submit-indexnow (push) Successful in 18s
2026-04-04 00:05:38 +08:00
ad44dde886 Refine frontend navigation, loading UI, and site copy
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m3s
docker-images / build-and-push (admin) (push) Successful in 4s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
2026-04-03 23:43:30 +08:00
99a57738e0 feat: 更新后端工作者内存限制为 1g,以优化性能和稳定性
All checks were successful
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 12s
docker-images / build-and-push (backend) (push) Successful in 27m48s
docker-images / build-and-push (frontend) (push) Successful in 15s
docker-images / submit-indexnow (push) Successful in 11s
2026-04-03 15:55:26 +08:00
cf00dc5e8e feat: 添加 AI 索引重建功能,优化相关 API 和工作流,增强内存管理配置
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Successful in 4m43s
docker-images / build-and-push (admin) (push) Successful in 42s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has started running
2026-04-03 15:48:33 +08:00
1df179c327 Refactor SEO and JSON-LD handling; improve layout and styles
All checks were successful
docker-images / resolve-build-targets (push) Successful in 5s
ui-regression / playwright-regression (push) Successful in 3m51s
docker-images / build-and-push (admin) (push) Successful in 4s
docker-images / build-and-push (backend) (push) Successful in 3s
docker-images / build-and-push (frontend) (push) Successful in 1m10s
docker-images / submit-indexnow (push) Successful in 19s
- Introduced `compactJsonLd` utility to filter out falsy values from JSON-LD arrays.
- Updated various pages to utilize `compactJsonLd` for cleaner JSON-LD handling.
- Refactored music playlist configuration in Header component.
- Enhanced BaseLayout with inline script for JSON-LD and removed unnecessary media attributes from stylesheets.
- Improved error handling in category and tag pages by simplifying response logic.
- Added new styles for home hero section and sidebar components to enhance UI.
- Adjusted layout components for better responsiveness and visual consistency.
2026-04-03 13:46:08 +08:00
0f2342a713 refactor: streamline homepage layout and enhance sidebar functionality
All checks were successful
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Successful in 4m15s
docker-images / build-and-push (admin) (push) Successful in 1m6s
docker-images / build-and-push (backend) (push) Successful in 4s
docker-images / build-and-push (frontend) (push) Successful in 1m19s
docker-images / submit-indexnow (push) Successful in 18s
- Removed unused FriendLinkCard import and adjusted sidebar friend links to display a maximum of three.
- Introduced a new popularPreviewLimit constant to limit the number of popular posts displayed.
- Enhanced the sidebar with quick links, popular content, and friend links sections, improving overall navigation.
- Updated the layout to use a grid system for better responsiveness and organization.
- Simplified the popular posts section by removing sorting options and adjusting the display logic.
- Improved accessibility and readability of various components, including command prompts and statistics.
2026-04-03 12:49:15 +08:00
83f3c8d249 feat: 更新样式和功能,优化徽章、登录页面和文章页面的布局,增强可访问性和用户体验 2026-04-03 04:10:35 +08:00
36d505ece6 feat: 添加站点设置中的 favicon URL 支持,更新相关接口和页面
All checks were successful
ui-regression / playwright-regression (push) Successful in 6m20s
docker-images / resolve-build-targets (push) Successful in 6s
docker-images / build-and-push (admin) (push) Successful in 25s
docker-images / build-and-push (backend) (push) Successful in 35s
docker-images / build-and-push (frontend) (push) Successful in 1m46s
docker-images / submit-indexnow (push) Successful in 15s
2026-04-03 02:13:27 +08:00
27d0827f3e feat: 增强维护模式和审计页面功能,优化构建流程
All checks were successful
docker-images / resolve-build-targets (push) Successful in 4s
ui-regression / playwright-regression (push) Successful in 5m55s
docker-images / build-and-push (admin) (push) Successful in 54s
docker-images / build-and-push (backend) (push) Successful in 4s
docker-images / build-and-push (frontend) (push) Successful in 1m8s
docker-images / submit-indexnow (push) Successful in 15s
2026-04-03 01:33:24 +08:00
9665c933b5 feat: update tag and timeline share panel copy for clarity and conciseness
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
2026-04-02 23:05:49 +08:00
6a50dd478c feat: refactor page navigation in frontend tests to use gotoPage function
Some checks failed
ui-regression / playwright-regression (push) Failing after 6m54s
2026-04-02 15:36:38 +08:00
ebfb9c7838 feat: enhance build process and add readiness checks for components
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m44s
docker-images / build-and-push (admin) (push) Successful in 1m13s
docker-images / build-and-push (backend) (push) Successful in 45m36s
docker-images / build-and-push (frontend) (push) Successful in 1m29s
docker-images / submit-indexnow (push) Successful in 18s
2026-04-02 14:57:01 +08:00
3628a46ed1 feat: add SharePanel component for social sharing with QR code support
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m47s
docker-images / build-and-push (push) Failing after 7s
docker-images / submit-indexnow (push) Has been skipped
- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms.
- Integrated QR code generation for WeChat sharing using the `qrcode` library.
- Added localization support for English and Chinese languages.
- Created utility functions in `seo.ts` for building article summaries and FAQs.
- Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries.
- Enhanced SEO capabilities with structured data for articles and pages.
2026-04-02 14:15:21 +08:00
a516be2e91 feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s
2026-04-02 03:43:37 +08:00
ee0bec4a78 test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
2026-04-02 00:55:34 +08:00
170 changed files with 25147 additions and 4972 deletions

View File

@@ -18,7 +18,121 @@ permissions:
packages: write
jobs:
resolve-build-targets:
runs-on: ubuntu-latest
outputs:
count: ${{ steps.targets.outputs.count }}
backend_changed: ${{ steps.targets.outputs.backend_changed }}
admin_changed: ${{ steps.targets.outputs.admin_changed }}
frontend_changed: ${{ steps.targets.outputs.frontend_changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve build targets
id: targets
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
CURRENT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
declare -A SELECTED=()
BUILD_ALL=0
if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
BUILD_ALL=1
fi
BEFORE="${BEFORE_SHA:-}"
if [ "${BUILD_ALL}" -ne 1 ] && { [ -z "${BEFORE}" ] || printf '%s' "${BEFORE}" | grep -Eq '^0+$'; }; then
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
BEFORE="$(git rev-parse HEAD^)"
else
BUILD_ALL=1
fi
fi
CHANGED_FILES=""
if [ "${BUILD_ALL}" -ne 1 ]; then
CHANGED_FILES="$(git diff --name-only "${BEFORE}" "${CURRENT_SHA}" || true)"
fi
while IFS= read -r path; do
[ -n "${path}" ] || continue
case "${path}" in
backend/*)
SELECTED[backend]=1
;;
frontend/*)
SELECTED[frontend]=1
;;
admin/*)
SELECTED[admin]=1
;;
deploy/docker/*|.gitea/workflows/backend-docker.yml)
BUILD_ALL=1
;;
esac
done <<< "${CHANGED_FILES}"
if [ "${BUILD_ALL}" -eq 1 ] || [ "${#SELECTED[@]}" -eq 0 ]; then
SELECTED[backend]=1
SELECTED[frontend]=1
SELECTED[admin]=1
fi
COMPONENTS=()
[ -n "${SELECTED[backend]:-}" ] && COMPONENTS+=(backend)
[ -n "${SELECTED[frontend]:-}" ] && COMPONENTS+=(frontend)
[ -n "${SELECTED[admin]:-}" ] && COMPONENTS+=(admin)
COMPONENTS_CSV="$(IFS=,; echo "${COMPONENTS[*]}")"
BACKEND_CHANGED=false
FRONTEND_CHANGED=false
ADMIN_CHANGED=false
COUNT=0
if [ -n "${SELECTED[backend]:-}" ]; then
BACKEND_CHANGED=true
COUNT=$((COUNT + 1))
fi
if [ -n "${SELECTED[frontend]:-}" ]; then
FRONTEND_CHANGED=true
COUNT=$((COUNT + 1))
fi
if [ -n "${SELECTED[admin]:-}" ]; then
ADMIN_CHANGED=true
COUNT=$((COUNT + 1))
fi
{
echo "count=${COUNT}"
echo "backend_changed=${BACKEND_CHANGED}"
echo "frontend_changed=${FRONTEND_CHANGED}"
echo "admin_changed=${ADMIN_CHANGED}"
} >> "$GITHUB_OUTPUT"
echo "Selected components: ${COMPONENTS_CSV}"
if [ -n "${CHANGED_FILES}" ]; then
echo "Changed files:"
printf '%s\n' "${CHANGED_FILES}"
else
echo "Changed files: <build all>"
fi
build-and-push:
name: build-and-push (${{ matrix.component }})
needs: resolve-build-targets
if: needs.resolve-build-targets.outputs.count != '0'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -39,10 +153,48 @@ jobs:
default_image_name: termi-astro-admin
steps:
- name: Decide whether to build current component
id: should_build
shell: bash
env:
COMPONENT: ${{ matrix.component }}
BACKEND_CHANGED: ${{ needs.resolve-build-targets.outputs.backend_changed }}
FRONTEND_CHANGED: ${{ needs.resolve-build-targets.outputs.frontend_changed }}
ADMIN_CHANGED: ${{ needs.resolve-build-targets.outputs.admin_changed }}
run: |
set -euo pipefail
SHOULD_BUILD=false
case "${COMPONENT}" in
backend)
SHOULD_BUILD="${BACKEND_CHANGED}"
;;
frontend)
SHOULD_BUILD="${FRONTEND_CHANGED}"
;;
admin)
SHOULD_BUILD="${ADMIN_CHANGED}"
;;
esac
echo "should_build=${SHOULD_BUILD}" >> "$GITHUB_OUTPUT"
echo "component=${COMPONENT}"
echo "should_build=${SHOULD_BUILD}"
- name: Skip unchanged component
if: steps.should_build.outputs.should_build != 'true'
shell: bash
env:
COMPONENT: ${{ matrix.component }}
run: echo "No changes detected for ${COMPONENT}, skipping docker build."
- name: Checkout
if: steps.should_build.outputs.should_build == 'true'
uses: actions/checkout@v4
- name: Resolve image metadata
if: steps.should_build.outputs.should_build == 'true'
id: meta
shell: bash
env:
@@ -103,6 +255,8 @@ jobs:
echo "tag_latest=latest"
echo "tag_branch=${SAFE_REF}"
echo "tag_sha=${SHORT_SHA}"
echo "cache_ref_branch=${IMAGE_BASE}:buildcache-${SAFE_REF}"
echo "cache_ref_shared=${IMAGE_BASE}:buildcache"
echo "frontend_public_api_base_url=${FRONTEND_PUBLIC_API_BASE_URL}"
echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}"
echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}"
@@ -110,6 +264,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Login registry
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }}
@@ -164,6 +319,7 @@ jobs:
fi
- name: Setup docker buildx
if: steps.should_build.outputs.should_build == 'true'
shell: bash
run: |
set -euo pipefail
@@ -177,6 +333,7 @@ jobs:
docker buildx inspect --bootstrap
- name: Login Docker Hub (optional)
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -191,6 +348,7 @@ jobs:
fi
- name: Build and push image
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
COMPONENT: ${{ matrix.component }}
@@ -200,6 +358,8 @@ jobs:
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
CACHE_REF_BRANCH: ${{ steps.meta.outputs.cache_ref_branch }}
CACHE_REF_SHARED: ${{ steps.meta.outputs.cache_ref_shared }}
FRONTEND_PUBLIC_API_BASE_URL: ${{ steps.meta.outputs.frontend_public_api_base_url }}
ADMIN_VITE_API_BASE: ${{ steps.meta.outputs.admin_vite_api_base }}
ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }}
@@ -222,8 +382,12 @@ jobs:
--file "${DOCKERFILE}" \
"${BUILD_ARGS[@]}" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from "type=registry,ref=${CACHE_REF_BRANCH}" \
--cache-from "type=registry,ref=${CACHE_REF_SHARED}" \
--cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_BRANCH}" \
--cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_LATEST}" \
--cache-to "type=registry,ref=${CACHE_REF_BRANCH},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF_SHARED},mode=max" \
--cache-to "type=inline" \
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
@@ -232,6 +396,7 @@ jobs:
"${CONTEXT_DIR}"
- name: Output image tags
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
COMPONENT: ${{ matrix.component }}
@@ -244,3 +409,57 @@ jobs:
echo "- ${IMAGE_BASE}:${TAG_LATEST}"
echo "- ${IMAGE_BASE}:${TAG_BRANCH}"
echo "- ${IMAGE_BASE}:${TAG_SHA}"
submit-indexnow:
needs:
- resolve-build-targets
- build-and-push
if: needs.resolve-build-targets.outputs.frontend_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Submit IndexNow (optional)
shell: bash
env:
INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
SITE_URL: ${{ vars.INDEXNOW_SITE_URL }}
PUBLIC_API_BASE_URL: ${{ vars.INDEXNOW_PUBLIC_API_BASE_URL }}
GITHUB_REF_NAME_VALUE: ${{ github.ref_name }}
run: |
set -euo pipefail
REF_NAME="${GITHUB_REF_NAME_VALUE:-${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}}"
if [ "${GITHUB_EVENT_NAME:-${GITEA_EVENT_NAME:-}}" != "push" ]; then
echo "Current event is not push, skip IndexNow submission."
exit 0
fi
if [ "${REF_NAME}" != "main" ] && [ "${REF_NAME}" != "master" ]; then
echo "Current ref '${REF_NAME}' is not main/master, skip IndexNow submission."
exit 0
fi
if [ -z "${INDEXNOW_KEY:-}" ]; then
echo "Missing INDEXNOW_KEY secret, skip IndexNow submission."
exit 0
fi
if [ -z "${SITE_URL:-}" ]; then
echo "Missing INDEXNOW_SITE_URL variable, skip IndexNow submission."
exit 0
fi
pnpm --dir frontend run indexnow:submit

View File

@@ -0,0 +1,88 @@
name: ui-regression
on:
workflow_dispatch:
jobs:
playwright-regression:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: |
frontend/pnpm-lock.yaml
admin/pnpm-lock.yaml
playwright-smoke/pnpm-lock.yaml
- name: Install frontend deps
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Install admin deps
working-directory: admin
run: pnpm install --frozen-lockfile
- name: Install Playwright deps
working-directory: playwright-smoke
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('playwright-smoke/pnpm-lock.yaml') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: playwright-smoke
run: pnpm exec playwright install --with-deps chromium
- name: Typecheck Playwright suite
working-directory: playwright-smoke
run: pnpm exec tsc -p tsconfig.json --noEmit
- name: Build frontend package
working-directory: frontend
env:
PUBLIC_API_BASE_URL: http://127.0.0.1:5159/api
run: pnpm build
- name: Build admin package
working-directory: admin
env:
VITE_API_BASE: http://127.0.0.1:5159
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
run: pnpm build
- name: Run frontend UI regression suite
id: ui_frontend
working-directory: playwright-smoke
continue-on-error: true
env:
PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:frontend
- name: Run admin UI regression suite
id: ui_admin
working-directory: playwright-smoke
continue-on-error: true
env:
PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:admin
- name: Mark workflow failed when any suite failed
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
run: exit 1

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.codex/
.codex-tmp/
.playwright-mcp/
.vscode/
.windsurf/

View File

@@ -22,7 +22,7 @@ Monorepo for the Termi blog system.
From the repository root:
```powershell
npm run dev
pnpm dev
```
This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs.
@@ -30,13 +30,14 @@ This starts `frontend + admin + backend` in a single Windows Terminal window wit
Common shortcuts:
```powershell
npm run dev:mcp
npm run dev:frontend
npm run dev:admin
npm run dev:backend
npm run dev:mcp-only
npm run stop
npm run restart
pnpm dev:mcp
pnpm dev:frontend
pnpm dev:admin
pnpm dev:backend
pnpm dev:mcp-only
pnpm stop
pnpm restart
pnpm test:ui
```
### PowerShell entrypoint
@@ -88,9 +89,11 @@ pnpm dev
```powershell
cd backend
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
cargo loco start 2>&1
cargo loco start --server-and-worker 2>&1
```
如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`
### Docker生产部署使用 Gitea Package 镜像)
补充部署分层与反代说明见:
@@ -142,6 +145,7 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en
- Secrets
- `REGISTRY_USERNAME`
- `REGISTRY_TOKEN`
- `INDEXNOW_KEY`(可选;如果要在主分支镜像发布后自动提交 IndexNow
- Variables可选
- `REGISTRY_HOST`(默认 `git.init.cool`
- `IMAGE_NAMESPACE`(默认仓库 owner
@@ -152,6 +156,16 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en
- `ADMIN_VITE_API_BASE`admin 镜像构建注入的 API 默认地址,默认 `http://localhost:5150`;运行时可被 `ADMIN_API_BASE_URL` 覆盖)
- `ADMIN_VITE_FRONTEND_BASE_URL`admin 镜像构建注入的前台跳转默认基址,默认 `http://localhost:4321`;运行时可被 `ADMIN_FRONTEND_BASE_URL` 覆盖)
- `ADMIN_VITE_BASENAME`(可选;如果 admin 要挂在 `/admin` 这类路径前缀下,构建时设置为 `/admin`
- `INDEXNOW_SITE_URL`(可选;自动提交 IndexNow 时使用的前台 canonical 域名,例如 `https://blog.init.cool`
- `INDEXNOW_PUBLIC_API_BASE_URL`(可选;如果站点公开 API 不是 `${INDEXNOW_SITE_URL}/api`,可在这里显式指定)
如果同时配置了 `INDEXNOW_KEY` + `INDEXNOW_SITE_URL`,主分支镜像发布成功后会自动执行一次:
```powershell
pnpm --dir frontend run indexnow:submit
```
用来把首页、文章、分类、标签、评测等 canonical URL 提交到 IndexNow。
### MCP Server

View File

@@ -38,6 +38,18 @@ const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage }
})
const PostPreviewPage = lazy(async () => {
const mod = await import('@/pages/post-preview-page')
return { default: mod.PostPreviewPage }
})
const PostComparePage = lazy(async () => {
const mod = await import('@/pages/post-compare-page')
return { default: mod.PostComparePage }
})
const PostPolishPage = lazy(async () => {
const mod = await import('@/pages/post-polish-page')
return { default: mod.PostPolishPage }
})
const CategoriesPage = lazy(async () => {
const mod = await import('@/pages/categories-page')
return { default: mod.CategoriesPage }
@@ -74,14 +86,18 @@ const SiteSettingsPage = lazy(async () => {
const mod = await import('@/pages/site-settings-page')
return { default: mod.SiteSettingsPage }
})
const AuditPage = lazy(async () => {
const mod = await import('@/pages/audit-page')
return { default: mod.AuditPage }
})
const SubscriptionsPage = lazy(async () => {
const mod = await import('@/pages/subscriptions-page')
return { default: mod.SubscriptionsPage }
})
const WorkersPage = lazy(async () => {
const mod = await import('@/pages/workers-page')
return { default: mod.WorkersPage }
})
const AuditPage = lazy(async () => {
const mod = await import('@/pages/audit-page')
return { default: mod.AuditPage }
})
type SessionContextValue = {
session: AdminSessionResponse
@@ -223,6 +239,56 @@ function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<PublicOnly />} />
<Route
path="/posts/preview"
element={
<RequireAuth>
<LazyRoute>
<PostPreviewPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/:slug/preview"
element={
<RequireAuth>
<LazyRoute>
<PostPreviewPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/compare"
element={
<RequireAuth>
<LazyRoute>
<PostComparePage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/:slug/compare"
element={
<RequireAuth>
<LazyRoute>
<PostComparePage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/polish"
element={
<RequireAuth>
<LazyRoute>
<PostPolishPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/"
element={
@@ -327,6 +393,14 @@ function AppRoutes() {
</LazyRoute>
}
/>
<Route
path="workers"
element={
<LazyRoute>
<WorkersPage />
</LazyRoute>
}
/>
<Route
path="audit"
element={

View File

@@ -16,6 +16,7 @@ import {
Settings,
Sparkles,
Tags,
Workflow,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
@@ -99,11 +100,17 @@ const primaryNav = [
description: '邮件 / Webhook 推送',
icon: BellRing,
},
{
to: '/workers',
label: 'Workers',
description: '异步任务 / 队列控制台',
icon: Workflow,
},
{
to: '/audit',
label: '审计',
description: '后台操作审计日志',
icon: Settings,
description: '后台操作日志与排障线索',
icon: History,
},
{
to: '/settings',

View File

@@ -0,0 +1,291 @@
import { Image as ImageIcon, Search, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminMediaObjectResponse } from '@/lib/types'
type MediaLibraryPickerDialogProps = {
open: boolean
selectedUrl?: string
preferredPrefix?: string
onClose: () => void
onSelect: (item: AdminMediaObjectResponse) => void
}
const DEFAULT_PREFIX_OPTIONS = [
'all',
'post-covers/',
'review-covers/',
'category-covers/',
'tag-covers/',
'site-assets/',
'seo-assets/',
'music-covers/',
'friend-link-avatars/',
'uploads/',
] as const
function prefixLabel(value: string) {
switch (value) {
case 'all':
return '全部目录'
case 'post-covers/':
return '文章封面'
case 'review-covers/':
return '评测封面'
case 'category-covers/':
return '分类封面'
case 'tag-covers/':
return '标签封面'
case 'site-assets/':
return '站点资源'
case 'seo-assets/':
return 'SEO 图片'
case 'music-covers/':
return '音乐封面'
case 'friend-link-avatars/':
return '友链头像'
case 'uploads/':
return '通用上传'
default:
return value
}
}
function isLikelyImage(item: AdminMediaObjectResponse) {
return /\.(png|jpe?g|webp|avif|gif|svg)$/i.test(item.key)
}
export function MediaLibraryPickerDialog({
open,
selectedUrl,
preferredPrefix,
onClose,
onSelect,
}: MediaLibraryPickerDialogProps) {
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
const [loading, setLoading] = useState(false)
const [prefixFilter, setPrefixFilter] = useState(preferredPrefix ?? 'all')
const [searchTerm, setSearchTerm] = useState('')
const prefixOptions = useMemo(
() => Array.from(new Set([preferredPrefix, ...DEFAULT_PREFIX_OPTIONS].filter(Boolean))) as string[],
[preferredPrefix],
)
useEffect(() => {
if (!open) {
return
}
setPrefixFilter(preferredPrefix ?? 'all')
setSearchTerm('')
}, [open, preferredPrefix])
useEffect(() => {
if (!open) {
return
}
let cancelled = false
async function loadItems() {
try {
setLoading(true)
const result = await adminApi.listMediaObjects({
prefix: prefixFilter === 'all' ? undefined : prefixFilter,
limit: 200,
})
if (!cancelled) {
setItems(result.items)
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof ApiError ? error.message : '加载媒体库失败。')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void loadItems()
return () => {
cancelled = true
}
}, [open, prefixFilter])
useEffect(() => {
if (!open) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[
item.key,
item.title ?? '',
item.alt_text ?? '',
item.caption ?? '',
...(item.tags ?? []),
]
.join('\n')
.toLowerCase()
.includes(keyword),
)
}, [items, searchTerm])
if (!open) {
return null
}
return (
<div
className="fixed inset-0 z-[70] bg-slate-950/70 px-4 py-5 backdrop-blur-sm xl:px-8 xl:py-8"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<div className="mx-auto flex h-full w-full max-w-7xl flex-col overflow-hidden rounded-[32px] border border-border/70 bg-background shadow-[0_40px_120px_rgba(15,23,42,0.45)]">
<div className="flex flex-col gap-4 border-b border-border/70 bg-background/95 px-6 py-5 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h3 className="text-2xl font-semibold tracking-tight"></h3>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
使 URL
</p>
</div>
</div>
<Button variant="ghost" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 border-b border-border/70 bg-background/80 px-6 py-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
{prefixOptions.map((option) => (
<option key={option} value={option}>
{prefixLabel(option)}
</option>
))}
</Select>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按 key / 标题 / alt / 标签搜索"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-[260px] rounded-[28px]" />
))}
</div>
) : filteredItems.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => {
const isSelected = selectedUrl === item.url
return (
<div
key={item.key}
className={`overflow-hidden rounded-[28px] border bg-background/75 ${
isSelected
? 'border-primary/40 shadow-[0_16px_44px_rgba(37,99,235,0.16)]'
: 'border-border/70'
}`}
>
<div className="aspect-[16/9] overflow-hidden border-b border-border/70 bg-muted/30">
{isLikelyImage(item) ? (
<img
src={item.url}
alt={item.alt_text ?? item.title ?? item.key}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
</div>
)}
</div>
<div className="space-y-3 p-4">
<div className="space-y-2">
<p className="line-clamp-1 text-sm font-medium">{item.title || item.key}</p>
<p className="line-clamp-2 break-all text-xs text-muted-foreground">{item.key}</p>
{item.tags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.slice(0, 3).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
{prefixLabel(item.key.split('/')[0] ? `${item.key.split('/')[0]}/` : 'uploads/')}
</span>
<Button
size="sm"
onClick={() => {
onSelect(item)
onClose()
}}
>
使
</Button>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-3 rounded-[28px] border border-dashed border-border/70 bg-background/40 text-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
<p></p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,256 @@
import { CheckSquare, Download, Images, Square, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { MediaLibraryPickerDialog } from '@/components/media-library-picker-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { adminApi, ApiError } from '@/lib/api'
import {
formatCompressionPreview,
prepareImageForUpload,
type MediaUploadTargetFormat,
} from '@/lib/image-compress'
import { cn } from '@/lib/utils'
type RemoteTargetFormat = 'original' | 'webp' | 'avif'
type MediaUrlControlsProps = {
value: string
onChange: (value: string) => void
prefix: string
contextLabel: string
mode?: 'image' | 'cover'
className?: string
remoteTitle?: string | null
accept?: string
dataTestIdPrefix?: string
}
function formatLabelForUploadTarget(value: MediaUploadTargetFormat) {
switch (value) {
case 'avif':
return 'AVIF'
case 'webp':
return 'WebP'
default:
return '自动'
}
}
export function MediaUrlControls({
value,
onChange,
prefix,
contextLabel,
mode = 'image',
className,
remoteTitle,
accept = 'image/*',
dataTestIdPrefix,
}: MediaUrlControlsProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [downloadingRemote, setDownloadingRemote] = useState(false)
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
const [remoteUrl, setRemoteUrl] = useState('')
const [remoteTargetFormat, setRemoteTargetFormat] = useState<RemoteTargetFormat>('original')
const [pickerOpen, setPickerOpen] = useState(false)
const quality = Number.parseFloat(compressQuality)
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
return (
<div className={cn('rounded-2xl border border-border/70 bg-background/55 p-4', className)}>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileInputRef}
className="hidden"
type="file"
accept={accept}
onChange={async (event) => {
const file = event.target.files?.item(0)
event.currentTarget.value = ''
if (!file) {
return
}
try {
setUploading(true)
const prepared = await prepareImageForUpload(file, {
compress: compressBeforeUpload,
quality: safeQuality,
targetFormat: uploadTargetFormat,
contextLabel: `${contextLabel}${file.name}`,
mode,
})
if (prepared.preview) {
toast.message(formatCompressionPreview(prepared.preview))
}
const uploaded = await adminApi.uploadMediaObjects([prepared.file], { prefix })
const url = uploaded.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但没有返回 URL')
}
if (compressBeforeUpload && uploadTargetFormat !== 'auto') {
const expectedMimeType =
uploadTargetFormat === 'avif' ? 'image/avif' : 'image/webp'
if (prepared.file.type !== expectedMimeType) {
toast.warning(
`当前环境无法直接导出 ${formatLabelForUploadTarget(uploadTargetFormat)},已回退为 ${prepared.file.type || '原格式'}`,
)
}
}
onChange(url)
toast.success('已上传到媒体库,并回填 URL。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '上传到媒体库失败。')
} finally {
setUploading(false)
}
}}
/>
<Button
type="button"
variant="outline"
disabled={uploading}
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-upload` : undefined}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
{uploading ? '上传中...' : '上传到媒体库'}
</Button>
<Button
type="button"
variant="outline"
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-library` : undefined}
onClick={() => setPickerOpen(true)}
>
<Images className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => setCompressBeforeUpload((current) => !current)}
>
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</Button>
<Select
value={uploadTargetFormat}
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
disabled={!compressBeforeUpload}
className="min-w-[180px]"
>
<option value="avif"> AVIF</option>
<option value="webp"> WebP</option>
<option value="auto"></option>
</Select>
<Input
className="w-[92px]"
value={compressQuality}
onChange={(event) => setCompressQuality(event.target.value)}
placeholder="0.82"
disabled={!compressBeforeUpload}
/>
</div>
<div className="space-y-3">
<Input
value={remoteUrl}
onChange={(event) => setRemoteUrl(event.target.value)}
placeholder="https://example.com/cover.webp"
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-url` : undefined}
/>
<div className="flex flex-wrap gap-3">
<div className="min-w-[220px] flex-1">
<Select
value={remoteTargetFormat}
onChange={(event) => setRemoteTargetFormat(event.target.value as RemoteTargetFormat)}
>
<option value="original"></option>
<option value="webp"> WebP</option>
<option value="avif"> AVIF</option>
</Select>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
disabled={!remoteUrl.trim() || downloadingRemote}
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-download` : undefined}
onClick={async () => {
if (!remoteUrl.trim()) {
toast.error('请先填写远程图片 URL。')
return
}
try {
setDownloadingRemote(true)
const result = await adminApi.downloadMediaObject({
sourceUrl: remoteUrl.trim(),
prefix,
targetFormat: remoteTargetFormat,
title: remoteTitle?.trim() || null,
sync: true,
})
if (!result.url) {
throw new Error(result.job_id ? `远程抓取已入队:#${result.job_id}` : '远程抓取完成但未返回 URL')
}
onChange(result.url)
setRemoteUrl('')
toast.success('远程素材已写入媒体库,并回填 URL。')
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: error instanceof Error
? error.message
: '远程抓取失败。',
)
} finally {
setDownloadingRemote(false)
}
}}
>
<Download className="h-4 w-4" />
{downloadingRemote ? '抓取中...' : '抓取到媒体库'}
</Button>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
/ URL
{value.trim() ? ' 当前已有值,可继续覆盖。' : ''}
</p>
</div>
<MediaLibraryPickerDialog
open={pickerOpen}
selectedUrl={value}
preferredPrefix={prefix}
onClose={() => setPickerOpen(false)}
onSelect={(item) => {
onChange(item.url)
toast.success('已从媒体库选中并回填 URL。')
}}
/>
</div>
)
}

View File

@@ -11,7 +11,7 @@ const badgeVariants = cva(
default: 'border-primary/20 bg-primary/10 text-primary',
secondary: 'border-border bg-secondary text-secondary-foreground',
outline: 'border-border/80 bg-background/60 text-muted-foreground',
success: 'border-emerald-500/20 bg-emerald-500/12 text-emerald-600',
success: 'border-emerald-300 bg-emerald-100 text-emerald-900',
warning: 'border-amber-500/20 bg-amber-500/12 text-amber-700',
danger: 'border-rose-500/20 bg-rose-500/12 text-rose-600',
},

View File

@@ -4,7 +4,9 @@ import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
type NativeSelectProps = React.ComponentProps<'select'>
type NativeSelectProps = React.ComponentProps<'select'> & {
'data-testid'?: string
}
type SelectOption = {
value: string
@@ -78,8 +80,11 @@ function getNextEnabledIndex(options: SelectOption[], currentIndex: number, dire
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
(
{
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
className,
'data-testid': dataTestId,
defaultValue,
disabled = false,
id,
@@ -134,12 +139,14 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
const rect = trigger.getBoundingClientRect()
const viewportPadding = 12
const gutter = 6
const minMenuWidth = 220
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
const spaceAbove = rect.top - viewportPadding
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
const maxAllowedWidth = window.innerWidth - viewportPadding * 2
const width = Math.min(Math.max(rect.width, minMenuWidth), maxAllowedWidth)
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
setMenuPlacement(openToTop ? 'top' : 'bottom')
@@ -434,6 +441,9 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
className="pointer-events-none absolute h-0 w-0 opacity-0"
defaultValue={defaultValue}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
data-testid={dataTestId}
id={id}
onBlur={onBlur}
onFocus={onFocus}
@@ -454,8 +464,11 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-haspopup="listbox"
className={triggerClasses}
data-testid={dataTestId}
data-state={open ? 'open' : 'closed'}
disabled={disabled}
onBlur={(event) => {

View File

@@ -7,8 +7,8 @@
--card-foreground: oklch(0.18 0.02 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.02 255);
--primary: oklch(0.57 0.17 255);
--primary-foreground: oklch(0.98 0.01 255);
--primary: oklch(0.5 0.16 255);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0.02 220);
--secondary-foreground: oklch(0.28 0.03 250);
--muted: oklch(0.95 0.01 250);
@@ -20,7 +20,7 @@
--border: oklch(0.9 0.01 250);
--input: oklch(0.91 0.01 250);
--ring: oklch(0.57 0.17 255);
--success: oklch(0.72 0.16 160);
--success: oklch(0.63 0.14 160);
--warning: oklch(0.81 0.16 78);
--radius: 1.15rem;
}

View File

@@ -1,10 +1,10 @@
import type {
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaBatchDeleteResponse,
AdminMediaDownloadResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminMediaMetadataResponse,
@@ -12,6 +12,7 @@ import type {
AdminMediaUploadResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminPostLocalizeImagesResponse,
AdminDashboardResponse,
AdminPostMetadataResponse,
AdminPostPolishResponse,
@@ -36,6 +37,7 @@ import type {
MarkdownDocumentResponse,
MarkdownImportResponse,
MediaAssetMetadataPayload,
MediaDownloadPayload,
NotificationDeliveryRecord,
PostPageResponse,
PostListQuery,
@@ -53,6 +55,10 @@ import type {
SubscriptionPayload,
SubscriptionRecord,
SubscriptionUpdatePayload,
WorkerJobListResponse,
WorkerJobRecord,
WorkerOverview,
WorkerTaskActionResponse,
TagRecord,
TaxonomyPayload,
UpdateCommentPayload,
@@ -236,7 +242,7 @@ export const adminApi = {
method: 'DELETE',
}),
testSubscription: (id: number) =>
request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, {
request<{ queued: boolean; id: number; delivery_id: number; job_id?: number | null }>(`/api/admin/subscriptions/${id}/test`, {
method: 'POST',
}),
listSubscriptionDeliveries: async (limit = 80) =>
@@ -248,6 +254,42 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ period }),
}),
getWorkersOverview: () => request<WorkerOverview>('/api/admin/workers/overview'),
listWorkerJobs: (query?: {
status?: string
jobKind?: string
workerName?: string
search?: string
limit?: number
}) =>
request<WorkerJobListResponse>(
appendQueryParams('/api/admin/workers/jobs', {
status: query?.status,
job_kind: query?.jobKind,
worker_name: query?.workerName,
search: query?.search,
limit: query?.limit,
}),
),
getWorkerJob: (id: number) => request<WorkerJobRecord>(`/api/admin/workers/jobs/${id}`),
cancelWorkerJob: (id: number) =>
request<WorkerJobRecord>(`/api/admin/workers/jobs/${id}/cancel`, {
method: 'POST',
}),
retryWorkerJob: (id: number) =>
request<WorkerTaskActionResponse>(`/api/admin/workers/jobs/${id}/retry`, {
method: 'POST',
}),
runRetryDeliveriesWorker: (limit?: number) =>
request<WorkerTaskActionResponse>('/api/admin/workers/tasks/retry-deliveries', {
method: 'POST',
body: JSON.stringify({ limit }),
}),
runDigestWorker: (period: 'weekly' | 'monthly') =>
request<WorkerTaskActionResponse>('/api/admin/workers/tasks/digest', {
method: 'POST',
body: JSON.stringify({ period }),
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
listCategories: () => request<CategoryRecord[]>('/api/admin/categories'),
@@ -319,7 +361,7 @@ export const adminApi = {
body: JSON.stringify(payload),
}),
reindexAi: () =>
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
request<WorkerTaskActionResponse>('/api/admin/ai/reindex', {
method: 'POST',
}),
testAiProvider: (provider: {
@@ -405,6 +447,22 @@ export const adminApi = {
body: formData,
})
},
downloadMediaObject: (payload: MediaDownloadPayload) =>
request<AdminMediaDownloadResponse>('/api/admin/storage/media/download', {
method: 'POST',
body: JSON.stringify({
source_url: payload.sourceUrl,
prefix: payload.prefix,
target_format:
payload.targetFormat && payload.targetFormat !== 'original' ? payload.targetFormat : null,
title: payload.title,
alt_text: payload.altText,
caption: payload.caption,
tags: payload.tags,
notes: payload.notes,
sync: payload.sync ?? false,
}),
}),
updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
request<AdminMediaMetadataResponse>('/api/admin/storage/media/metadata', {
method: 'PATCH',
@@ -433,6 +491,14 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ markdown }),
}),
localizePostMarkdownImages: (payload: { markdown: string; prefix?: string | null }) =>
request<AdminPostLocalizeImagesResponse>('/api/admin/posts/localize-images', {
method: 'POST',
body: JSON.stringify({
markdown: payload.markdown,
prefix: payload.prefix,
}),
}),
polishReviewDescription: (payload: AdminReviewPolishRequest) =>
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
method: 'POST',

View File

@@ -11,6 +11,13 @@ export interface CompressionResult {
preview: CompressionPreview | null
}
export type MediaUploadTargetFormat = 'auto' | 'avif' | 'webp'
interface ProcessedVariant {
file: File
preview: CompressionPreview
}
interface ProcessImageOptions {
quality: number
maxWidth: number
@@ -83,6 +90,427 @@ function deriveFileName(file: File, mimeType: string) {
return `processed${extension}`
}
function buildPreview(originalSize: number, compressedSize: number): CompressionPreview {
const savedBytes = originalSize - compressedSize
const savedRatio = originalSize > 0 ? savedBytes / originalSize : 0
return {
originalSize,
compressedSize,
savedBytes,
savedRatio,
}
}
function formatLabelForMimeType(mimeType: string) {
switch (mimeType) {
case 'image/avif':
return 'AVIF'
case 'image/webp':
return 'WebP'
case 'image/png':
return 'PNG'
default:
return 'JPEG'
}
}
function defaultPreferredFormats(file: File, coverMode = false) {
if (coverMode) {
return ['image/avif', 'image/webp', 'image/jpeg']
}
if (file.type === 'image/png') {
return ['image/png', 'image/webp', 'image/jpeg']
}
return ['image/webp', 'image/avif', 'image/jpeg']
}
function preferredFormatsForTarget(file: File, targetFormat: MediaUploadTargetFormat, coverMode = false) {
switch (targetFormat) {
case 'avif':
return ['image/avif', 'image/webp', 'image/jpeg']
case 'webp':
return ['image/webp', 'image/jpeg']
default:
return defaultPreferredFormats(file, coverMode)
}
}
async function buildProcessedVariants(file: File, options: ProcessImageOptions): Promise<ProcessedVariant[]> {
const variants: ProcessedVariant[] = []
const requestedFormats = Array.from(new Set(options.preferredFormats))
for (const format of requestedFormats) {
const processed = await processImage(file, {
...options,
preferredFormats: [format],
})
if (processed.type !== format) {
continue
}
if (variants.some((item) => item.file.type === processed.type)) {
continue
}
variants.push({
file: processed,
preview: buildPreview(file.size, processed.size),
})
}
return variants
}
function ensureImageChoiceDialogStyles() {
const styleId = 'termi-image-choice-dialog-style'
if (document.getElementById(styleId)) {
return
}
const style = document.createElement('style')
style.id = styleId
style.textContent = `
.termi-image-choice-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(6px);
}
.termi-image-choice-dialog {
width: min(680px, 100%);
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.96);
color: #0f172a;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
overflow: hidden;
}
.termi-image-choice-header {
padding: 20px 22px 12px;
}
.termi-image-choice-title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.45;
}
.termi-image-choice-description {
margin: 8px 0 0;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.termi-image-choice-body {
padding: 0 22px 22px;
display: grid;
gap: 12px;
}
.termi-image-choice-note {
border-radius: 16px;
border: 1px solid rgba(59, 130, 246, 0.18);
background: rgba(239, 246, 255, 0.92);
color: #1d4ed8;
padding: 12px 14px;
font-size: 13px;
line-height: 1.7;
}
.termi-image-choice-option {
display: grid;
gap: 8px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: #f8fafc;
padding: 14px 16px;
cursor: pointer;
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.termi-image-choice-option:hover {
border-color: rgba(37, 99, 235, 0.35);
transform: translateY(-1px);
}
.termi-image-choice-option.is-selected {
border-color: rgba(37, 99, 235, 0.52);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
background: rgba(239, 246, 255, 0.92);
}
.termi-image-choice-option-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.termi-image-choice-option-label {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
line-height: 1.5;
}
.termi-image-choice-option-label input {
margin: 0;
}
.termi-image-choice-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.termi-image-choice-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.termi-image-choice-badge--recommended {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.termi-image-choice-badge--neutral {
background: rgba(148, 163, 184, 0.14);
color: #475569;
}
.termi-image-choice-meta {
display: grid;
gap: 4px;
color: #475569;
font-size: 13px;
line-height: 1.65;
}
.termi-image-choice-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 0 22px 22px;
}
.termi-image-choice-button {
border: 0;
border-radius: 999px;
padding: 11px 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.termi-image-choice-button:hover {
transform: translateY(-1px);
}
.termi-image-choice-button--ghost {
background: rgba(148, 163, 184, 0.18);
color: #334155;
}
.termi-image-choice-button--primary {
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
box-shadow: 0 12px 32px rgba(37, 99, 235, 0.26);
}
`
document.head.appendChild(style)
}
async function showImageChoiceDialog(options: {
title: string
description: string
note?: string
choices: Array<{
id: string
title: string
meta: string[]
badge?: string
recommended?: boolean
}>
defaultChoiceId: string
confirmLabel?: string
cancelLabel?: string
}) {
ensureImageChoiceDialogStyles()
return new Promise<string>((resolve) => {
const overlay = document.createElement('div')
overlay.className = 'termi-image-choice-overlay'
const dialog = document.createElement('div')
dialog.className = 'termi-image-choice-dialog'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
dialog.setAttribute('aria-label', options.title)
const header = document.createElement('div')
header.className = 'termi-image-choice-header'
header.innerHTML = `
<h3 class="termi-image-choice-title"></h3>
<p class="termi-image-choice-description"></p>
`
const titleEl = header.querySelector('.termi-image-choice-title')
const descriptionEl = header.querySelector('.termi-image-choice-description')
if (titleEl) titleEl.textContent = options.title
if (descriptionEl) descriptionEl.textContent = options.description
const body = document.createElement('div')
body.className = 'termi-image-choice-body'
if (options.note) {
const note = document.createElement('div')
note.className = 'termi-image-choice-note'
note.textContent = options.note
body.appendChild(note)
}
let selectedChoiceId = options.defaultChoiceId
const optionElements: HTMLElement[] = []
for (const choice of options.choices) {
const option = document.createElement('label')
option.className = 'termi-image-choice-option'
option.dataset.choiceId = choice.id
const top = document.createElement('div')
top.className = 'termi-image-choice-option-top'
const label = document.createElement('div')
label.className = 'termi-image-choice-option-label'
const input = document.createElement('input')
input.type = 'radio'
input.name = 'termi-image-choice'
input.value = choice.id
input.checked = choice.id === selectedChoiceId
const text = document.createElement('span')
text.textContent = choice.title
label.append(input, text)
const badges = document.createElement('div')
badges.className = 'termi-image-choice-badges'
if (choice.recommended) {
const recommended = document.createElement('span')
recommended.className = 'termi-image-choice-badge termi-image-choice-badge--recommended'
recommended.textContent = '推荐'
badges.appendChild(recommended)
}
if (choice.badge) {
const badge = document.createElement('span')
badge.className = 'termi-image-choice-badge termi-image-choice-badge--neutral'
badge.textContent = choice.badge
badges.appendChild(badge)
}
top.append(label, badges)
const meta = document.createElement('div')
meta.className = 'termi-image-choice-meta'
for (const line of choice.meta) {
const item = document.createElement('div')
item.textContent = line
meta.appendChild(item)
}
option.append(top, meta)
option.addEventListener('click', () => {
selectedChoiceId = choice.id
optionElements.forEach((element) => {
const checked = element.dataset.choiceId === selectedChoiceId
element.classList.toggle('is-selected', checked)
const radio = element.querySelector('input[type="radio"]') as HTMLInputElement | null
if (radio) {
radio.checked = checked
}
})
})
option.classList.toggle('is-selected', choice.id === selectedChoiceId)
optionElements.push(option)
body.appendChild(option)
}
const actions = document.createElement('div')
actions.className = 'termi-image-choice-actions'
const cancelButton = document.createElement('button')
cancelButton.type = 'button'
cancelButton.className = 'termi-image-choice-button termi-image-choice-button--ghost'
cancelButton.textContent = options.cancelLabel ?? '保留原图'
const confirmButton = document.createElement('button')
confirmButton.type = 'button'
confirmButton.className = 'termi-image-choice-button termi-image-choice-button--primary'
confirmButton.textContent = options.confirmLabel ?? '使用所选版本'
const cleanup = () => {
overlay.remove()
document.removeEventListener('keydown', handleKeyDown)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
cleanup()
resolve('original')
}
}
cancelButton.addEventListener('click', () => {
cleanup()
resolve('original')
})
confirmButton.addEventListener('click', () => {
cleanup()
resolve(selectedChoiceId)
})
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
cleanup()
resolve('original')
}
})
actions.append(cancelButton, confirmButton)
dialog.append(header, body, actions)
overlay.appendChild(dialog)
document.body.appendChild(overlay)
document.addEventListener('keydown', handleKeyDown)
const defaultInput = overlay.querySelector(
`input[value="${CSS.escape(options.defaultChoiceId)}"]`,
) as HTMLInputElement | null
defaultInput?.focus()
})
}
async function processImage(file: File, options: ProcessImageOptions): Promise<File> {
if (!canTransformWithCanvas(file)) {
return file
@@ -161,33 +589,29 @@ async function maybeProcessImageWithPrompt(
const contextLabel = options?.contextLabel ?? '图片上传'
const forceProcessed = options?.forceProcessed ?? false
const processOptions: ProcessImageOptions = {
quality,
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
preferredFormats:
options?.preferredFormats && options.preferredFormats.length
? options.preferredFormats
: file.type === 'image/png'
? ['image/png', 'image/webp', 'image/jpeg']
: ['image/webp', 'image/avif', 'image/jpeg'],
coverWidth: options?.coverWidth,
coverHeight: options?.coverHeight,
}
let processed: File
try {
processed = await processImage(file, {
quality,
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
preferredFormats:
options?.preferredFormats && options.preferredFormats.length
? options.preferredFormats
: file.type === 'image/png'
? ['image/png', 'image/webp', 'image/jpeg']
: ['image/webp', 'image/avif', 'image/jpeg'],
coverWidth: options?.coverWidth,
coverHeight: options?.coverHeight,
})
processed = await processImage(file, processOptions)
} catch {
return { file, usedCompressed: false, preview: null }
}
const savedBytes = file.size - processed.size
const savedRatio = file.size > 0 ? savedBytes / file.size : 0
const preview: CompressionPreview = {
originalSize: file.size,
compressedSize: processed.size,
savedBytes,
savedRatio,
}
const preview = buildPreview(file.size, processed.size)
const { savedRatio } = preview
if (!forceProcessed && processed.size >= file.size) {
return { file, usedCompressed: false, preview }
@@ -201,30 +625,80 @@ async function maybeProcessImageWithPrompt(
return { file: processed, usedCompressed: true, preview }
}
const deltaText =
savedBytes >= 0
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)`
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)`
let variants: ProcessedVariant[]
try {
variants = await buildProcessedVariants(file, processOptions)
} catch {
variants = [
{
file: processed,
preview,
},
]
}
const intro = forceProcessed
? `${contextLabel}: 已生成规范化版本。`
: `${contextLabel}: 检测到可压缩空间。`
const selectableVariants = forceProcessed
? variants
: variants.filter((item) => item.file.size < file.size && item.preview.savedRatio >= minSavingsRatio)
const useProcessed = window.confirm(
[
intro,
`原始: ${formatBytes(file.size)}`,
`处理后: ${formatBytes(processed.size)}`,
deltaText,
'',
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
].join('\n'),
if (!selectableVariants.length) {
return { file, usedCompressed: false, preview }
}
const recommendedVariant = selectableVariants[0]
const missingPreferredFormats = processOptions.preferredFormats.filter(
(format) => !variants.some((item) => item.file.type === format),
)
const note =
missingPreferredFormats.length > 0
? `当前环境未提供 ${missingPreferredFormats.map(formatLabelForMimeType).join(' / ')} 编码能力,因此这里只展示可实际生成的格式。`
: undefined
const choice = await showImageChoiceDialog({
title: forceProcessed ? `${contextLabel}:已生成规范化版本` : `${contextLabel}:检测到可压缩空间`,
description: forceProcessed
? '可以直接保留原图,也可以选择更适合上传的规范化版本。'
: '可以直接保留原图,也可以选择体积更合适的版本再上传。',
note,
choices: [
{
id: 'original',
title: `保留原图(${file.name}`,
meta: [
`当前文件: ${formatBytes(file.size)}`,
`格式: ${formatLabelForMimeType(file.type || 'image/jpeg')}`,
],
badge: '原图',
},
...selectableVariants.map((item, index) => {
const variantSavedBytes = item.preview.savedBytes
const variantSavedRatio = item.preview.savedRatio
return {
id: item.file.type,
title: `${formatLabelForMimeType(item.file.type)} 版本`,
meta: [
`处理后: ${formatBytes(item.file.size)}`,
variantSavedBytes >= 0
? `节省: ${formatBytes(variantSavedBytes)} (${(variantSavedRatio * 100).toFixed(1)}%)`
: `体积增加: ${formatBytes(Math.abs(variantSavedBytes))} (${Math.abs(variantSavedRatio * 100).toFixed(1)}%)`,
],
badge: item.file.name.replace(/^.*(\.[A-Za-z0-9]+)$/, '$1').toLowerCase(),
recommended: index === 0,
}
}),
],
defaultChoiceId: recommendedVariant.file.type,
confirmLabel: '使用所选版本',
cancelLabel: '保留原图',
})
const selectedVariant = selectableVariants.find((item) => item.file.type === choice)
const useProcessed = Boolean(selectedVariant)
return {
file: useProcessed ? processed : file,
file: selectedVariant?.file ?? file,
usedCompressed: useProcessed,
preview,
preview: selectedVariant?.preview ?? preview,
}
}
@@ -235,6 +709,7 @@ export async function maybeCompressImageWithPrompt(
ask?: boolean
minSavingsRatio?: number
contextLabel?: string
preferredFormats?: string[]
},
): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, options)
@@ -248,13 +723,14 @@ export async function normalizeCoverImageWithPrompt(
contextLabel?: string
width?: number
height?: number
preferredFormats?: string[]
},
): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, {
quality: options?.quality ?? 0.82,
ask: options?.ask ?? true,
contextLabel: options?.contextLabel ?? '封面图规范化',
preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'],
preferredFormats: options?.preferredFormats ?? ['image/avif', 'image/webp', 'image/jpeg'],
coverWidth: Math.max(options?.width ?? 1600, 640),
coverHeight: Math.max(options?.height ?? 900, 360),
forceProcessed: true,
@@ -262,6 +738,42 @@ export async function normalizeCoverImageWithPrompt(
})
}
export async function prepareImageForUpload(
file: File,
options?: {
compress?: boolean
quality?: number
targetFormat?: MediaUploadTargetFormat
contextLabel?: string
mode?: 'image' | 'cover'
},
): Promise<CompressionResult> {
const compress = options?.compress ?? true
if (!compress) {
return { file, usedCompressed: false, preview: null }
}
const targetFormat = options?.targetFormat ?? 'auto'
const mode = options?.mode ?? 'image'
const preferredFormats = preferredFormatsForTarget(file, targetFormat, mode === 'cover')
if (mode === 'cover') {
return normalizeCoverImageWithPrompt(file, {
quality: options?.quality ?? 0.82,
ask: false,
contextLabel: options?.contextLabel ?? '封面图规范化上传',
preferredFormats,
})
}
return maybeCompressImageWithPrompt(file, {
quality: options?.quality ?? 0.82,
ask: false,
contextLabel: options?.contextLabel ?? '媒体上传',
preferredFormats,
})
}
export function formatCompressionPreview(preview: CompressionPreview | null) {
if (!preview) {
return ''

View File

@@ -61,22 +61,38 @@ export function savePolishWindowResult(
return payload
}
export function consumePolishWindowResult(key: string | null) {
if (!key) {
return null
}
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
const raw = window.localStorage.getItem(storageKey)
function parsePolishWindowResult(raw: string | null) {
if (!raw) {
return null
}
window.localStorage.removeItem(storageKey)
try {
return JSON.parse(raw) as PolishWindowResult
} catch {
return null
}
}
export function readPolishWindowResult(key: string | null) {
if (!key) {
return null
}
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
return parsePolishWindowResult(window.localStorage.getItem(storageKey))
}
export function consumePolishWindowResult(key: string | null) {
if (!key) {
return null
}
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
const parsed = parsePolishWindowResult(window.localStorage.getItem(storageKey))
if (!parsed) {
return null
}
window.localStorage.removeItem(storageKey)
return parsed
}

View File

@@ -125,6 +125,79 @@ export interface SubscriptionDigestResponse {
skipped: number
}
export interface WorkerCatalogEntry {
worker_name: string
job_kind: string
label: string
description: string
queue_name: string | null
supports_cancel: boolean
supports_retry: boolean
}
export interface WorkerStats {
worker_name: string
job_kind: string
label: string
queued: number
running: number
succeeded: number
failed: number
cancelled: number
last_job_at: string | null
}
export interface WorkerOverview {
total_jobs: number
queued: number
running: number
succeeded: number
failed: number
cancelled: number
active_jobs: number
worker_stats: WorkerStats[]
catalog: WorkerCatalogEntry[]
}
export interface WorkerJobRecord {
created_at: string
updated_at: string
id: number
parent_job_id: number | null
job_kind: string
worker_name: string
display_name: string | null
status: string
queue_name: string | null
requested_by: string | null
requested_source: string | null
trigger_mode: string | null
payload: Record<string, unknown> | null
result: Record<string, unknown> | null
error_text: string | null
tags: unknown[] | Record<string, unknown> | null
related_entity_type: string | null
related_entity_id: string | null
attempts_count: number
max_attempts: number
cancel_requested: boolean
queued_at: string | null
started_at: string | null
finished_at: string | null
can_cancel: boolean
can_retry: boolean
}
export interface WorkerJobListResponse {
total: number
jobs: WorkerJobRecord[]
}
export interface WorkerTaskActionResponse {
queued: boolean
job: WorkerJobRecord
}
export interface DashboardStats {
total_posts: number
total_comments: number
@@ -276,6 +349,8 @@ export interface AdminAnalyticsResponse {
recent_events: AnalyticsRecentEvent[]
providers_last_7d: AnalyticsProviderBucket[]
top_referrers: AnalyticsReferrerBucket[]
ai_referrers_last_7d: AnalyticsReferrerBucket[]
ai_discovery_page_views_last_7d: number
popular_posts: AnalyticsPopularPost[]
daily_activity: AnalyticsDailyBucket[]
}
@@ -299,6 +374,9 @@ export interface AdminSiteSettingsResponse {
location: string | null
tech_stack: string[]
music_playlist: MusicTrack[]
music_enabled: boolean
maintenance_mode_enabled: boolean
maintenance_access_code: string | null
ai_enabled: boolean
paragraph_comments_enabled: boolean
comment_verification_mode: HumanVerificationMode
@@ -334,8 +412,10 @@ export interface AdminSiteSettingsResponse {
media_r2_public_base_url: string | null
media_r2_access_key_id: string | null
media_r2_secret_access_key: string | null
seo_favicon_url: string | null
seo_default_og_image: string | null
seo_default_twitter_handle: string | null
seo_wechat_share_qr_enabled: boolean
notification_webhook_url: string | null
notification_channel_type: 'webhook' | 'ntfy' | string
notification_comment_enabled: boolean
@@ -375,6 +455,9 @@ export interface SiteSettingsPayload {
location?: string | null
techStack?: string[]
musicPlaylist?: MusicTrack[]
musicEnabled?: boolean
maintenanceModeEnabled?: boolean
maintenanceAccessCode?: string | null
aiEnabled?: boolean
paragraphCommentsEnabled?: boolean
commentVerificationMode?: HumanVerificationMode | null
@@ -407,8 +490,10 @@ export interface SiteSettingsPayload {
mediaR2PublicBaseUrl?: string | null
mediaR2AccessKeyId?: string | null
mediaR2SecretAccessKey?: string | null
seoFaviconUrl?: string | null
seoDefaultOgImage?: string | null
seoDefaultTwitterHandle?: string | null
seoWechatShareQrEnabled?: boolean
notificationWebhookUrl?: string | null
notificationChannelType?: 'webhook' | 'ntfy' | string | null
notificationCommentEnabled?: boolean
@@ -460,11 +545,6 @@ export interface TaxonomyPayload {
seoDescription?: string | null
}
export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
}
export interface AdminAiProviderTestResponse {
provider: string
endpoint: string
@@ -533,6 +613,28 @@ export interface AdminMediaReplaceResponse {
url: string
}
export interface MediaDownloadPayload {
sourceUrl: string
prefix?: string | null
targetFormat?: 'original' | 'webp' | 'avif' | null
title?: string | null
altText?: string | null
caption?: string | null
tags?: string[]
notes?: string | null
sync?: boolean
}
export interface AdminMediaDownloadResponse {
queued: boolean
job_id: number | null
status: string | null
key: string | null
url: string | null
size_bytes: number | null
content_type: string | null
}
export interface MediaAssetMetadataPayload {
key: string
title?: string | null
@@ -661,6 +763,27 @@ export interface AdminPostPolishResponse {
polished_markdown: string
}
export interface AdminPostLocalizeImagesFailure {
source_url: string
error: string
}
export interface AdminPostLocalizedImageItem {
source_url: string
localized_url: string
key: string
}
export interface AdminPostLocalizeImagesResponse {
markdown: string
detected_count: number
localized_count: number
uploaded_count: number
failed_count: number
items: AdminPostLocalizedImageItem[]
failures: AdminPostLocalizeImagesFailure[]
}
export interface AdminReviewPolishRequest {
title: string
reviewType: string

View File

@@ -0,0 +1,127 @@
import type { WorkerJobRecord } from "@/lib/types";
type WorkerProgressShape = {
phase?: string;
message?: string;
total_chunks?: number;
processed_chunks?: number;
total_batches?: number;
current_batch?: number;
batch_size?: number;
percent?: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function getWorkerProgress(
job: Pick<WorkerJobRecord, "result">,
): WorkerProgressShape | null {
const result = asRecord(job.result);
if (!result) {
return null;
}
const nested = asRecord(result.progress);
const source = nested ?? result;
const percent = asNumber(source.percent);
const totalChunks = asNumber(source.total_chunks);
const processedChunks = asNumber(source.processed_chunks);
const totalBatches = asNumber(source.total_batches);
const currentBatch = asNumber(source.current_batch);
const batchSize = asNumber(source.batch_size);
const phase = asText(source.phase) ?? asText(result.phase) ?? undefined;
const message = asText(source.message) ?? asText(result.message) ?? undefined;
if (
percent === null &&
totalChunks === null &&
processedChunks === null &&
totalBatches === null &&
currentBatch === null &&
batchSize === null &&
!phase &&
!message
) {
return null;
}
return {
phase,
message,
total_chunks: totalChunks ?? undefined,
processed_chunks: processedChunks ?? undefined,
total_batches: totalBatches ?? undefined,
current_batch: currentBatch ?? undefined,
batch_size: batchSize ?? undefined,
percent: percent ?? undefined,
};
}
export function formatWorkerProgress(
job: Pick<WorkerJobRecord, "result">,
): string | null {
const progress = getWorkerProgress(job);
if (!progress) {
return null;
}
const percentText =
typeof progress.percent === "number"
? `${Math.max(0, Math.min(100, Math.round(progress.percent)))}%`
: null;
const chunkText =
typeof progress.processed_chunks === "number" &&
typeof progress.total_chunks === "number"
? `${progress.processed_chunks}/${progress.total_chunks} 分块`
: null;
const batchText =
typeof progress.current_batch === "number" &&
typeof progress.total_batches === "number" &&
progress.total_batches > 0
? `${progress.current_batch}/${progress.total_batches}`
: null;
const details = [percentText, chunkText, batchText]
.filter(Boolean)
.join(" · ");
if (progress.message && details) {
return `${progress.message} ${details}`;
}
return progress.message ?? (details || null);
}
export function getWorkerProgressPercent(
job: Pick<WorkerJobRecord, "result">,
): number | null {
const progress = getWorkerProgress(job);
if (typeof progress?.percent !== "number") {
return null;
}
return Math.max(0, Math.min(100, Math.round(progress.percent)));
}

View File

@@ -1,4 +1,4 @@
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search, Sparkles } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -80,6 +80,31 @@ function formatDuration(value: number | null) {
return `${minutes}${restSeconds}`
}
function formatReferrerLabel(value: string) {
switch (value) {
case 'chatgpt-search':
return 'ChatGPT Search'
case 'perplexity':
return 'Perplexity'
case 'copilot-bing':
return 'Copilot / Bing'
case 'gemini':
return 'Gemini'
case 'claude':
return 'Claude'
case 'google':
return 'Google'
case 'duckduckgo':
return 'DuckDuckGo'
case 'kagi':
return 'Kagi'
case 'direct':
return 'Direct'
default:
return value
}
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -197,6 +222,11 @@ export function AnalyticsPage() {
icon: Clock3,
},
]
const aiDiscoveryShare =
data.content_overview.page_views_last_7d > 0
? (data.ai_discovery_page_views_last_7d / data.content_overview.page_views_last_7d) * 100
: 0
const aiDiscoveryTopSource = data.ai_referrers_last_7d[0]
return (
<div className="space-y-6">
@@ -241,6 +271,94 @@ export function AnalyticsPage() {
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<Card className="border-primary/15 bg-gradient-to-br from-primary/5 via-card to-card">
<CardContent className="flex flex-col gap-5 pt-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">AI </Badge>
<span className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</span>
</div>
<div className="text-3xl font-semibold tracking-tight">
{data.ai_discovery_page_views_last_7d}
</div>
<p className="text-sm leading-6 text-muted-foreground">
ChatGPT SearchPerplexityCopilot/BingGeminiClaude
AI / 访
</p>
</div>
<div className="grid gap-3 sm:min-w-72 sm:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
page_view
</p>
<p className="mt-3 text-2xl font-semibold">{formatPercent(aiDiscoveryShare)}</p>
<p className="mt-2 text-sm text-muted-foreground">
page_view{data.content_overview.page_views_last_7d}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<p className="mt-3 text-2xl font-semibold">
{aiDiscoveryTopSource ? formatReferrerLabel(aiDiscoveryTopSource.referrer) : '暂无'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{aiDiscoveryTopSource ? `${aiDiscoveryTopSource.count} 次访问` : '等待来源数据'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI
</CardTitle>
<CardDescription>
便 GEO AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.ai_referrers_last_7d.length ? (
data.ai_referrers_last_7d.map((item) => {
const width = `${
Math.max(
(item.count / Math.max(data.ai_discovery_page_views_last_7d, 1)) * 100,
8,
)
}%`
return (
<div key={item.referrer} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{formatReferrerLabel(item.referrer)}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})
) : (
<p className="text-sm text-muted-foreground">
7 AI
</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
@@ -491,7 +609,7 @@ export function AnalyticsPage() {
key={item.referrer}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="line-clamp-1 font-medium">{item.referrer}</span>
<span className="line-clamp-1 font-medium">{formatReferrerLabel(item.referrer)}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))

View File

@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -218,6 +219,7 @@ export function CategoriesPage() {
</CardHeader>
<CardContent className="space-y-4">
<Input
data-testid="categories-search"
placeholder="按分类名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
@@ -229,6 +231,7 @@ export function CategoriesPage() {
<button
key={item.id}
type="button"
data-testid={`category-item-${item.slug}`}
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
@@ -286,6 +289,7 @@ export function CategoriesPage() {
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="分类名称" hint="例如:前端工程、随笔、工具链。">
<Input
data-testid="category-name-input"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="输入分类名称"
@@ -293,19 +297,32 @@ export function CategoriesPage() {
</FormField>
<FormField label="分类 slug" hint="留空时自动从英文名称生成;中文建议手填。">
<Input
data-testid="category-slug-input"
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
placeholder="frontend-engineering"
/>
</FormField>
<FormField label="封面图 URL" hint="可选,用于前台分类头图。">
<Input
value={form.coverImage}
onChange={(event) =>
setForm((current) => ({ ...current, coverImage: event.target.value }))
}
placeholder="https://cdn.example.com/covers/frontend.jpg"
/>
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<div className="space-y-3">
<Input
value={form.coverImage}
onChange={(event) =>
setForm((current) => ({ ...current, coverImage: event.target.value }))
}
placeholder="https://cdn.example.com/covers/frontend.jpg"
/>
<MediaUrlControls
value={form.coverImage}
onChange={(coverImage) =>
setForm((current) => ({ ...current, coverImage }))
}
prefix="category-covers/"
contextLabel="分类封面上传"
remoteTitle={form.name || form.slug || '分类封面'}
dataTestIdPrefix="category-cover"
/>
</div>
</FormField>
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
<div className="flex items-center gap-3">
@@ -377,7 +394,7 @@ export function CategoriesPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
<Button onClick={() => void handleSave()} disabled={saving} data-testid="category-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
</Button>
@@ -388,6 +405,7 @@ export function CategoriesPage() {
variant="ghost"
onClick={() => void handleDelete()}
disabled={!selectedItem || deleting}
data-testid="category-delete"
className="text-rose-600 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />

View File

@@ -898,6 +898,7 @@ export function CommentsPage() {
setManualMatcherValue('')
}}
disabled={!manualMatcherValue.trim()}
data-testid="comment-blacklist-add"
>
<Shield className="h-4 w-4" />
@@ -908,6 +909,7 @@ export function CommentsPage() {
{blacklist.map((item) => (
<div
key={item.id}
data-testid={`blacklist-item-${item.id}`}
className="rounded-2xl border border-border/70 bg-background/40 p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -929,6 +931,7 @@ export function CommentsPage() {
size="sm"
variant="outline"
disabled={actingBlacklistId === item.id}
data-testid={`blacklist-toggle-${item.id}`}
onClick={async () => {
try {
setActingBlacklistId(item.id)
@@ -959,6 +962,7 @@ export function CommentsPage() {
size="sm"
variant="danger"
disabled={actingBlacklistId === item.id}
data-testid={`blacklist-delete-${item.id}`}
onClick={async () => {
if (!window.confirm('确定删除这条黑名单规则吗?')) {
return

View File

@@ -6,16 +6,26 @@ import {
MessageSquareWarning,
RefreshCcw,
Rss,
Sparkles,
Star,
Tags,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
Workflow,
} from "lucide-react";
import { startTransition, useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/admin-format";
import {
Table,
TableBody,
@@ -23,9 +33,9 @@ import {
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
} from "@/components/ui/table";
import { adminApi, ApiError } from "@/lib/api";
import { buildFrontendUrl } from "@/lib/frontend-url";
import {
formatCommentScope,
formatPostStatus,
@@ -34,8 +44,12 @@ import {
formatPostVisibility,
formatReviewStatus,
formatReviewType,
} from '@/lib/admin-format'
import type { AdminDashboardResponse } from '@/lib/types'
} from "@/lib/admin-format";
import type {
AdminAnalyticsResponse,
AdminDashboardResponse,
WorkerOverview,
} from "@/lib/types";
function StatCard({
label,
@@ -43,17 +57,21 @@ function StatCard({
note,
icon: Icon,
}: {
label: string
value: number
note: string
icon: typeof Rss
label: string;
value: number;
note: string;
icon: typeof Rss;
}) {
return (
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{label}
</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">
{value}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
@@ -61,44 +79,83 @@ function StatCard({
</div>
</CardContent>
</Card>
)
);
}
function formatAiSourceLabel(value: string) {
switch (value) {
case "chatgpt-search":
return "ChatGPT Search";
case "perplexity":
return "Perplexity";
case "copilot-bing":
return "Copilot / Bing";
case "gemini":
return "Gemini";
case "claude":
return "Claude";
case "google":
return "Google";
case "duckduckgo":
return "DuckDuckGo";
case "kagi":
return "Kagi";
case "direct":
return "Direct";
default:
return value;
}
}
export function DashboardPage() {
const [data, setData] = useState<AdminDashboardResponse | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [data, setData] = useState<AdminDashboardResponse | null>(null);
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
null,
);
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
null,
);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadDashboard = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
setRefreshing(true);
}
const next = await adminApi.dashboard()
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
adminApi.dashboard(),
adminApi.getWorkersOverview(),
adminApi.analytics(),
]);
startTransition(() => {
setData(next)
})
setData(next);
setWorkerOverview(nextWorkerOverview);
setAnalytics(nextAnalytics);
});
if (showToast) {
toast.success('仪表盘已刷新。')
toast.success("仪表盘已刷新。");
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
return;
}
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
toast.error(
error instanceof ApiError ? error.message : "无法加载仪表盘。",
);
} finally {
setLoading(false)
setRefreshing(false)
setLoading(false);
setRefreshing(false);
}
}, [])
}, []);
useEffect(() => {
void loadDashboard(false)
}, [loadDashboard])
void loadDashboard(false);
}, [loadDashboard]);
if (loading || !data) {
if (loading || !data || !workerOverview || !analytics) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
@@ -106,26 +163,29 @@ export function DashboardPage() {
<Skeleton key={index} className="h-44 rounded-3xl" />
))}
</div>
<Skeleton className="h-[420px] rounded-3xl" />
<div className="grid gap-6 xl:grid-cols-[1.25fr_0.95fr]">
<Skeleton className="h-[420px] rounded-3xl" />
<Skeleton className="h-[420px] rounded-3xl" />
</div>
</div>
)
);
}
const statCards = [
{
label: '文章总数',
label: "文章总数",
value: data.stats.total_posts,
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
icon: Rss,
},
{
label: '待审核评论',
label: "待审核评论",
value: data.stats.pending_comments,
note: '等待审核处理',
note: "等待审核处理",
icon: MessageSquareWarning,
},
{
label: '发布待办',
label: "发布待办",
value:
data.stats.draft_posts +
data.stats.scheduled_posts +
@@ -135,18 +195,32 @@ export function DashboardPage() {
icon: Clock3,
},
{
label: '分类数量',
label: "分类数量",
value: data.stats.total_categories,
note: `当前共有 ${data.stats.total_tags} 个标签`,
icon: FolderTree,
},
{
label: 'AI 分块',
label: "AI 分块",
value: data.stats.ai_chunks,
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
icon: BrainCircuit,
},
]
{
label: "Worker 活动",
value: workerOverview.active_jobs,
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
icon: Workflow,
},
];
const aiTrafficShare =
analytics.content_overview.page_views_last_7d > 0
? (analytics.ai_discovery_page_views_last_7d /
analytics.content_overview.page_views_last_7d) *
100
: 0;
const topAiSource = analytics.ai_referrers_last_7d[0];
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
return (
<div className="space-y-6">
@@ -156,14 +230,15 @@ export function DashboardPage() {
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI
AI
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<a href={buildFrontendUrl("/ask")} target="_blank" rel="noreferrer">
<ArrowUpRight className="h-4 w-4" />
AI
</a>
@@ -174,7 +249,7 @@ export function DashboardPage() {
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
{refreshing ? "刷新中..." : "刷新"}
</Button>
</div>
</div>
@@ -190,9 +265,7 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</div>
<Badge variant="outline">{data.recent_posts.length} </Badge>
</CardHeader>
@@ -214,9 +287,13 @@ export function DashboardPage() {
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{post.title}</span>
{post.pinned ? <Badge variant="success"></Badge> : null}
{post.pinned ? (
<Badge variant="success"></Badge>
) : null}
</div>
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
<p className="font-mono text-xs text-muted-foreground">
{post.slug}
</p>
</div>
</TableCell>
<TableCell className="uppercase text-muted-foreground">
@@ -224,12 +301,18 @@ export function DashboardPage() {
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
<Badge variant="outline">
{formatPostStatus(post.status)}
</Badge>
<Badge variant="secondary">
{formatPostVisibility(post.visibility)}
</Badge>
</div>
</TableCell>
<TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
<TableCell className="text-muted-foreground">
{post.created_at}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -240,19 +323,19 @@ export function DashboardPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
AI
</CardDescription>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">{data.site.site_name}</p>
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
<p className="mt-1 text-sm text-muted-foreground">
{data.site.site_url}
</p>
</div>
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
<Badge variant={data.site.ai_enabled ? "success" : "warning"}>
{data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
</Badge>
</div>
</div>
@@ -263,7 +346,9 @@ export function DashboardPage() {
</p>
<div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
<span className="text-3xl font-semibold">
{data.stats.total_reviews}
</span>
<Star className="mb-1 h-4 w-4 text-amber-500" />
</div>
</div>
@@ -272,7 +357,9 @@ export function DashboardPage() {
</p>
<div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
<span className="text-3xl font-semibold">
{data.stats.total_links}
</span>
<Tags className="mb-1 h-4 w-4 text-primary" />
</div>
</div>
@@ -284,25 +371,35 @@ export function DashboardPage() {
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.draft_posts}
</p>
<p className="text-xs text-muted-foreground">稿</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.scheduled_posts}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.offline_posts}
</p>
<p className="text-xs text-muted-foreground">线</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
<p className="text-2xl font-semibold">
{data.stats.expired_posts}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline"> {data.stats.private_posts}</Badge>
<Badge variant="outline"> {data.stats.unlisted_posts}</Badge>
<Badge variant="outline">
{data.stats.unlisted_posts}
</Badge>
</div>
</div>
@@ -311,9 +408,203 @@ export function DashboardPage() {
AI
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
{data.site.ai_last_indexed_at
? formatDateTime(data.site.ai_last_indexed_at)
: "站点还没有建立过索引。"}
</p>
</div>
<div className="rounded-2xl border border-primary/20 bg-gradient-to-br from-primary/8 via-background/90 to-background/70 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
GEO / AI
</p>
<p className="mt-3 text-3xl font-semibold">
{analytics.ai_discovery_page_views_last_7d}
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
7 ChatGPT
SearchPerplexityCopilot/BingGeminiClaude
访
</p>
</div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Sparkles className="h-5 w-5" />
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
访
</div>
<div className="mt-2 text-2xl font-semibold">
{Math.round(aiTrafficShare)}%
</div>
<div className="mt-1 text-xs text-muted-foreground">
7 page_view
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
</div>
<div className="mt-2 text-base font-semibold">
{topAiSource
? formatAiSourceLabel(topAiSource.referrer)
: "暂无"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{topAiSource
? `${topAiSource.count} 次访问`
: "等待来源数据"}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
</div>
<div className="mt-2 text-2xl font-semibold">
{totalAiSourceBuckets}
</div>
<div className="mt-1 text-xs text-muted-foreground">
AI
</div>
</div>
</div>
{analytics.ai_referrers_last_7d.length ? (
<div className="mt-4 space-y-3">
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
const width = `${Math.max(
(item.count /
Math.max(
analytics.ai_discovery_page_views_last_7d,
1,
)) *
100,
8,
)}%`;
return (
<div key={item.referrer} className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">
{formatAiSourceLabel(item.referrer)}
</span>
<span className="text-muted-foreground">
{item.count}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
);
})}
</div>
) : (
<p className="mt-4 text-sm text-muted-foreground">
7 AI
</p>
)}
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Worker
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{workerOverview.queued}{" "}
{workerOverview.running} {workerOverview.failed}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link
to={
workerOverview.failed > 0
? "/workers?status=failed"
: "/workers"
}
data-testid="dashboard-worker-open"
>
</Link>
</Button>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Link
to="/workers?status=queued"
data-testid="dashboard-worker-card-queued"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Queued
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.queued}
</div>
</Link>
<Link
to="/workers?status=running"
data-testid="dashboard-worker-card-running"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Running
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.running}
</div>
</Link>
<Link
to="/workers?status=failed"
data-testid="dashboard-worker-card-failed"
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Failed
</div>
<div className="mt-2 text-2xl font-semibold text-foreground">
{workerOverview.failed}
</div>
</Link>
</div>
{workerOverview.worker_stats.length ? (
<div className="mt-4 space-y-2">
{workerOverview.worker_stats.slice(0, 3).map((item) => (
<Link
key={item.worker_name}
to={`/workers?worker=${encodeURIComponent(item.worker_name)}`}
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
>
<div>
<div className="font-medium text-foreground">
{item.label}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{item.worker_name}
</div>
</div>
<div className="text-right text-xs text-muted-foreground">
<div>
Q {item.queued} · R {item.running}
</div>
<div>ERR {item.failed}</div>
</div>
</Link>
))}
</div>
) : null}
</div>
</CardContent>
</Card>
</div>
@@ -323,11 +614,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</div>
<Badge variant="warning">{data.pending_comments.length} </Badge>
<Badge variant="warning">
{data.pending_comments.length}
</Badge>
</CardHeader>
<CardContent>
<Table>
@@ -356,7 +647,9 @@ export function DashboardPage() {
<TableCell className="font-mono text-xs text-muted-foreground">
{comment.post_slug}
</TableCell>
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
<TableCell className="text-muted-foreground">
{comment.created_at}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -369,11 +662,11 @@ export function DashboardPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</div>
<Badge variant="warning">{data.pending_friend_links.length} </Badge>
<Badge variant="warning">
{data.pending_friend_links.length}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.pending_friend_links.map((link) => (
@@ -404,9 +697,7 @@ export function DashboardPage() {
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.recent_reviews.map((review) => (
@@ -417,11 +708,14 @@ export function DashboardPage() {
<div className="min-w-0">
<p className="font-medium">{review.title}</p>
<p className="mt-1 text-sm text-muted-foreground">
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
{formatReviewType(review.review_type)} ·{" "}
{formatReviewStatus(review.status)}
</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold">{review.rating}/5</div>
<div className="text-lg font-semibold">
{review.rating}/5
</div>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{review.review_date}
</p>
@@ -433,5 +727,5 @@ export function DashboardPage() {
</div>
</div>
</div>
)
);
}

View File

@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -378,13 +379,25 @@ export function FriendLinksPage() {
}
/>
</FormField>
<FormField label="头像 URL">
<Input
value={form.avatarUrl}
onChange={(event) =>
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
}
/>
<FormField label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
<div className="space-y-3">
<Input
value={form.avatarUrl}
onChange={(event) =>
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
}
/>
<MediaUrlControls
value={form.avatarUrl}
onChange={(avatarUrl) =>
setForm((current) => ({ ...current, avatarUrl }))
}
prefix="friend-link-avatars/"
contextLabel="友链头像上传"
remoteTitle={form.siteName || form.siteUrl || '友链头像'}
dataTestIdPrefix="friend-link-avatar"
/>
</div>
</FormField>
<FormField label="分类">
<Input

View File

@@ -21,7 +21,10 @@ export function LoginPage({
const [password, setPassword] = useState('admin123')
return (
<div className="flex min-h-screen items-center justify-center px-4 py-10">
<main
className="flex min-h-screen items-center justify-center px-4 py-10"
aria-labelledby="admin-login-title"
>
<div className="grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card className="overflow-hidden border-primary/12 bg-gradient-to-br from-card via-card to-primary/5">
<CardHeader className="space-y-4">
@@ -57,7 +60,7 @@ export function LoginPage({
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<CardTitle id="admin-login-title" className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LockKeyhole className="h-5 w-5" />
</span>
@@ -119,6 +122,6 @@ export function LoginPage({
</CardContent>
</Card>
</div>
</div>
</main>
)
}

View File

@@ -1,6 +1,7 @@
import {
CheckSquare,
Copy,
Download,
Image as ImageIcon,
RefreshCcw,
Replace,
@@ -10,6 +11,7 @@ import {
Upload,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
@@ -21,8 +23,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api'
import {
formatCompressionPreview,
maybeCompressImageWithPrompt,
normalizeCoverImageWithPrompt,
prepareImageForUpload,
type MediaUploadTargetFormat,
} from '@/lib/image-compress'
import type { AdminMediaObjectResponse } from '@/lib/types'
import { FormField } from '@/components/form-field'
@@ -58,6 +60,42 @@ const defaultMetadataForm: MediaMetadataFormState = {
notes: '',
}
type RemoteDownloadFormState = {
sourceUrl: string
title: string
altText: string
caption: string
tags: string
notes: string
}
const defaultRemoteDownloadForm: RemoteDownloadFormState = {
sourceUrl: '',
title: '',
altText: '',
caption: '',
tags: '',
notes: '',
}
function normalizeMediaTags(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
}
function normalizeMediaItem(item: AdminMediaObjectResponse): AdminMediaObjectResponse {
return {
...item,
tags: normalizeMediaTags(item.tags),
}
}
function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
if (!item) {
return defaultMetadataForm
@@ -67,7 +105,7 @@ function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFor
title: item.title ?? '',
altText: item.alt_text ?? '',
caption: item.caption ?? '',
tags: item.tags.join(', '),
tags: normalizeMediaTags(item.tags).join(', '),
notes: item.notes ?? '',
}
}
@@ -103,6 +141,13 @@ export function MediaPage() {
const [metadataSaving, setMetadataSaving] = useState(false)
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>(
defaultRemoteDownloadForm,
)
const [remoteTargetFormat, setRemoteTargetFormat] = useState<'original' | 'webp' | 'avif'>('original')
const [downloadingRemote, setDownloadingRemote] = useState(false)
const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null)
const loadItems = useCallback(async (showToast = false) => {
try {
@@ -111,8 +156,9 @@ export function MediaPage() {
}
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
const normalizedItems = result.items.map(normalizeMediaItem)
startTransition(() => {
setItems(result.items)
setItems(normalizedItems)
setProvider(result.provider)
setBucket(result.bucket)
})
@@ -174,22 +220,18 @@ export function MediaPage() {
const quality = Number.parseFloat(compressQuality)
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
const normalizeCover =
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
const mode =
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' ? 'cover' : 'image'
const result: File[] = []
for (const file of files) {
const compressed = normalizeCover
? await normalizeCoverImageWithPrompt(file, {
quality: safeQuality,
ask: true,
contextLabel: `封面规范化上传${file.name}`,
})
: await maybeCompressImageWithPrompt(file, {
quality: safeQuality,
ask: true,
contextLabel: `媒体库上传(${file.name}`,
})
const compressed = await prepareImageForUpload(file, {
compress: true,
quality: safeQuality,
targetFormat: uploadTargetFormat,
contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}${file.name}`,
mode,
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
}
@@ -219,6 +261,7 @@ export function MediaPage() {
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
data-testid="media-batch-delete"
onClick={async () => {
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
return
@@ -259,22 +302,36 @@ export function MediaPage() {
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
<option value="category-covers/"></option>
<option value="tag-covers/"></option>
<option value="site-assets/"></option>
<option value="seo-assets/">SEO </option>
<option value="music-covers/"></option>
<option value="friend-link-avatars/"></option>
<option value="uploads/"></option>
</Select>
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
<option value="category-covers/"></option>
<option value="tag-covers/"></option>
<option value="site-assets/"></option>
<option value="seo-assets/"> SEO </option>
<option value="music-covers/"></option>
<option value="friend-link-avatars/"></option>
<option value="uploads/"></option>
</Select>
<Input
data-testid="media-search"
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
<div className="grid gap-3 lg:grid-cols-[1fr_auto_180px_96px_auto]">
<Input
data-testid="media-upload-input"
type="file"
multiple
accept="image/*"
@@ -291,6 +348,15 @@ export function MediaPage() {
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</Button>
<Select
value={uploadTargetFormat}
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
disabled={!compressBeforeUpload}
>
<option value="avif"> AVIF</option>
<option value="webp"> WebP</option>
<option value="auto"></option>
</Select>
<Input
className="w-[96px]"
value={compressQuality}
@@ -300,6 +366,7 @@ export function MediaPage() {
/>
<Button
disabled={!uploadFiles.length || uploading}
data-testid="media-upload"
onClick={async () => {
try {
setUploading(true)
@@ -325,10 +392,169 @@ export function MediaPage() {
<p className="text-xs text-muted-foreground">
{uploadFiles.length}
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。'
: ''}
</p>
) : null}
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<p className="text-xs leading-6 text-muted-foreground">
访 / PDF worker
</p>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<FormField label="远程 URL">
<Input
data-testid="media-remote-url"
value={remoteDownloadForm.sourceUrl}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
sourceUrl: event.target.value,
}))
}
placeholder="https://example.com/cover.webp"
/>
</FormField>
<FormField label="抓取格式">
<Select
value={remoteTargetFormat}
onChange={(event) =>
setRemoteTargetFormat(event.target.value as 'original' | 'webp' | 'avif')
}
>
<option value="original"></option>
<option value="webp"> WebP</option>
<option value="avif"> AVIF</option>
</Select>
</FormField>
<FormField label="标题">
<Input
data-testid="media-remote-title"
value={remoteDownloadForm.title}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
title: event.target.value,
}))
}
placeholder="远程抓取封面"
/>
</FormField>
<FormField label="Alt 文本">
<Input
value={remoteDownloadForm.altText}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
altText: event.target.value,
}))
}
placeholder="终端风格封面图"
/>
</FormField>
<FormField label="标签">
<Input
value={remoteDownloadForm.tags}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
tags: event.target.value,
}))
}
placeholder="remote, cover"
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Caption">
<Textarea
value={remoteDownloadForm.caption}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
caption: event.target.value,
}))
}
rows={3}
placeholder="适合记录图片用途或展示位置。"
/>
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="内部备注">
<Textarea
value={remoteDownloadForm.notes}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
notes: event.target.value,
}))
}
rows={3}
placeholder="可选:记录素材来源、版权说明等。"
/>
</FormField>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Button
data-testid="media-remote-download"
disabled={!remoteDownloadForm.sourceUrl.trim() || downloadingRemote}
onClick={async () => {
if (!remoteDownloadForm.sourceUrl.trim()) {
toast.error('请先填写远程 URL。')
return
}
try {
setDownloadingRemote(true)
const result = await adminApi.downloadMediaObject({
sourceUrl: remoteDownloadForm.sourceUrl.trim(),
prefix: uploadPrefix,
targetFormat: remoteTargetFormat,
title: remoteDownloadForm.title.trim() || null,
altText: remoteDownloadForm.altText.trim() || null,
caption: remoteDownloadForm.caption.trim() || null,
tags: parseTagList(remoteDownloadForm.tags),
notes: remoteDownloadForm.notes.trim() || null,
})
setLastRemoteDownloadJobId(result.job_id)
toast.success(
result.job_id
? `远程抓取任务已入队:#${result.job_id}`
: '远程抓取请求已提交。',
)
setRemoteDownloadForm(defaultRemoteDownloadForm)
window.setTimeout(() => {
void loadItems(false)
}, 1500)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '远程抓取失败。')
} finally {
setDownloadingRemote(false)
}
}}
>
<Download className="h-4 w-4" />
{downloadingRemote ? '抓取中...' : '抓取远程素材'}
</Button>
{lastRemoteDownloadJobId ? (
<Button variant="outline" asChild data-testid="media-last-remote-job">
<Link to={`/workers?job=${lastRemoteDownloadJobId}`}></Link>
</Button>
) : null}
</div>
</div>
</CardContent>
</Card>
@@ -399,6 +625,7 @@ export function MediaPage() {
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={metadataSaving}
data-testid="media-save-metadata"
onClick={async () => {
if (!activeItem) {
return
@@ -423,7 +650,7 @@ export function MediaPage() {
title: result.title,
alt_text: result.alt_text,
caption: result.caption,
tags: result.tags,
tags: normalizeMediaTags(result.tags),
notes: result.notes,
}
: item,
@@ -473,10 +700,12 @@ export function MediaPage() {
{filteredItems.map((item, index) => {
const selected = selectedKeys.includes(item.key)
const replaceInputId = `replace-media-${index}`
const itemTags = normalizeMediaTags(item.tags)
return (
<Card
key={item.key}
data-testid={`media-item-${index}`}
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
>
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
@@ -504,9 +733,9 @@ export function MediaPage() {
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
{item.tags.length ? (
{itemTags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.slice(0, 4).map((tag) => (
{itemTags.slice(0, 4).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
@@ -515,7 +744,12 @@ export function MediaPage() {
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
<Button
size="sm"
variant="outline"
onClick={() => setActiveKey(item.key)}
data-testid={`media-edit-${index}`}
>
</Button>
<Button
@@ -541,6 +775,7 @@ export function MediaPage() {
</Button>
<input
id={replaceInputId}
data-testid={`media-replace-input-${index}`}
className="hidden"
type="file"
accept="image/*"
@@ -583,6 +818,7 @@ export function MediaPage() {
size="sm"
variant="danger"
disabled={deletingKey === item.key || replacingKey === item.key}
data-testid={`media-delete-${index}`}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return

View File

@@ -1,5 +1,6 @@
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
import { Badge } from '@/components/ui/badge'
@@ -17,15 +18,6 @@ type CompareState = {
draftMarkdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/compare\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
@@ -35,7 +27,8 @@ function getDraftKey() {
}
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const { slug: routeSlug } = useParams<{ slug?: string }>()
const slug = slugOverride ?? routeSlug ?? ''
const [state, setState] = useState<CompareState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -49,6 +42,28 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
setError(null)
const draft = loadDraftWindowSnapshot(getDraftKey())
if (draft && (!slug || draft.slug === slug)) {
if (!active) {
return
}
startTransition(() => {
setState({
title: draft.title,
slug: draft.slug,
path: draft.path,
savedMarkdown: draft.savedMarkdown,
draftMarkdown: draft.markdown,
})
})
return
}
if (!slug) {
throw new Error('缺少文章 slug无法加载改动对比。')
}
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),
@@ -63,8 +78,8 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
title: post.title ?? slug,
slug,
path: markdown.path,
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
draftMarkdown: draft?.markdown ?? markdown.markdown,
savedMarkdown: markdown.markdown,
draftMarkdown: markdown.markdown,
})
})
} catch (loadError) {

View File

@@ -1,5 +1,6 @@
import { ExternalLink, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { MarkdownPreview } from '@/components/markdown-preview'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
@@ -17,15 +18,6 @@ type PreviewState = {
markdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
@@ -35,7 +27,8 @@ function getDraftKey() {
}
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const { slug: routeSlug } = useParams<{ slug?: string }>()
const slug = slugOverride ?? routeSlug ?? ''
const [state, setState] = useState<PreviewState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -50,7 +43,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const draft = loadDraftWindowSnapshot(getDraftKey())
if (draft && draft.slug === slug) {
if (draft && (!slug || draft.slug === slug)) {
if (!active) {
return
}
@@ -66,6 +59,10 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
return
}
if (!slug) {
throw new Error('缺少文章 slug无法加载独立预览。')
}
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),

View File

@@ -4,15 +4,16 @@ import {
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
FilePlus2,
FileUp,
FolderOpen,
GitCompareArrows,
PencilLine,
RefreshCcw,
RotateCcw,
Save,
Trash2,
Upload,
WandSparkles,
X,
} from 'lucide-react'
@@ -22,6 +23,7 @@ import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { LazyDiffEditor } from '@/components/lazy-monaco'
import { MediaUrlControls } from '@/components/media-url-controls'
import { MarkdownPreview } from '@/components/markdown-preview'
import {
MarkdownWorkbench,
@@ -47,13 +49,16 @@ import {
formatPostVisibility,
postTagsToList,
} from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
import {
consumePolishWindowResult,
readPolishWindowResult,
saveDraftWindowSnapshot,
type DraftWindowSnapshot,
type PolishWindowResult,
} from '@/lib/post-draft-window'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
import type {
@@ -206,6 +211,14 @@ const defaultCreateForm: CreatePostFormState = {
const defaultWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit']
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
const POSTS_PAGE_SIZE_OPTIONS = [12, 24, 48] as const
const ADMIN_BASENAME =
((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace(/\/$/, '')
const POLISH_RESULT_STORAGE_PREFIX = 'termi-admin-post-polish-result:'
function buildAdminRoute(path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${ADMIN_BASENAME}${normalizedPath}` || normalizedPath
}
function formatWorkbenchPanelLabel(panel: MarkdownWorkbenchPanel) {
switch (panel) {
@@ -242,6 +255,17 @@ function buildVirtualPostPath(slug: string) {
return `article://posts/${normalizedSlug}`
}
function buildInlineImagePrefix(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
return `post-inline-images/${normalized || 'draft'}`
}
function parseImageList(value: string) {
return value
.split('\n')
@@ -791,8 +815,6 @@ export function PostsPage() {
const { slug } = useParams()
const importInputRef = useRef<HTMLInputElement | null>(null)
const folderImportInputRef = useRef<HTMLInputElement | null>(null)
const editorCoverInputRef = useRef<HTMLInputElement | null>(null)
const createCoverInputRef = useRef<HTMLInputElement | null>(null)
const [posts, setPosts] = useState<PostRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -806,8 +828,8 @@ export function PostsPage() {
useState(false)
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
const [localizingEditorImages, setLocalizingEditorImages] = useState(false)
const [localizingCreateImages, setLocalizingCreateImages] = useState(false)
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
const [editor, setEditor] = useState<PostFormState | null>(null)
@@ -828,6 +850,8 @@ export function PostsPage() {
const [sortKey, setSortKey] = useState('updated_at_desc')
const [totalPosts, setTotalPosts] = useState(0)
const [totalPages, setTotalPages] = useState(1)
const editorPolishDraftKeyRef = useRef<string | null>(null)
const createPolishDraftKeyRef = useRef<string | null>(null)
const { sortBy, sortOrder } = useMemo(() => {
switch (sortKey) {
@@ -930,6 +954,7 @@ export function PostsPage() {
useEffect(() => {
setEditorMode('workspace')
setEditorPanels(defaultWorkbenchPanels)
editorPolishDraftKeyRef.current = null
if (!slug) {
setEditor(null)
@@ -942,6 +967,12 @@ export function PostsPage() {
void loadEditor(slug)
}, [loadEditor, slug])
useEffect(() => {
if (!createDialogOpen) {
createPolishDraftKeyRef.current = null
}
}, [createDialogOpen])
useEffect(() => {
if (!metadataDialog && !slug && !createDialogOpen) {
return
@@ -1024,6 +1055,175 @@ export function PostsPage() {
normalizeMarkdown(buildCreateMarkdownForWindow(defaultCreateForm)),
[createForm],
)
const buildEditorDraftSnapshot = useCallback((): Omit<DraftWindowSnapshot, 'createdAt'> | null => {
if (!editor) {
return null
}
return {
title: editor.title.trim() || editor.slug,
slug: editor.slug,
path: editor.path,
markdown: buildDraftMarkdownForWindow(editor),
savedMarkdown: editor.savedMarkdown,
}
}, [editor])
const buildCreateDraftSnapshot = useCallback((): Omit<DraftWindowSnapshot, 'createdAt'> => {
const fallbackSlug = createForm.slug.trim() || 'new-post'
return {
title: createForm.title.trim() || createForm.slug.trim() || '新建草稿',
slug: fallbackSlug,
path: buildVirtualPostPath(fallbackSlug),
markdown: buildCreateMarkdownForWindow(createForm),
savedMarkdown: buildCreateMarkdownForWindow(defaultCreateForm),
}
}, [createForm])
const openDraftWorkbenchWindow = useCallback(
(
path: string,
snapshot: Omit<DraftWindowSnapshot, 'createdAt'>,
extraQuery?: Record<string, string>,
) => {
const draftKey = saveDraftWindowSnapshot(snapshot)
const url = new URL(buildAdminRoute(path), window.location.origin)
url.searchParams.set('draftKey', draftKey)
Object.entries(extraQuery ?? {}).forEach(([key, value]) => {
if (value) {
url.searchParams.set(key, value)
}
})
const popup = window.open(
url.toString(),
'_blank',
'popup=yes,width=1560,height=980,resizable=yes,scrollbars=yes',
)
if (!popup) {
toast.error('浏览器拦截了独立工作台窗口,请允许当前站点打开新窗口后重试。')
return null
}
popup.focus()
return draftKey
},
[],
)
const applyExternalPolishResult = useCallback(
(result: PolishWindowResult) => {
if (result.target === 'editor') {
if (!editor) {
return false
}
startTransition(() => {
setEditor((current) =>
current ? applyPolishedEditorState(current, result.markdown) : current,
)
setEditorPolish(null)
setEditorMode('workspace')
})
toast.success('独立 AI 润色结果已回填到当前文章。')
return true
}
if (!createDialogOpen) {
return false
}
startTransition(() => {
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
setCreatePolish(null)
setCreateMode('workspace')
})
toast.success('独立 AI 润色结果已回填到新建草稿。')
return true
},
[createDialogOpen, editor],
)
const flushPendingPolishResult = useCallback(
(draftKey: string | null) => {
const pending = readPolishWindowResult(draftKey)
if (!pending || !applyExternalPolishResult(pending)) {
return false
}
consumePolishWindowResult(draftKey)
return true
},
[applyExternalPolishResult],
)
useEffect(() => {
const tryFlushAll = () => {
if (flushPendingPolishResult(editorPolishDraftKeyRef.current)) {
editorPolishDraftKeyRef.current = null
}
if (flushPendingPolishResult(createPolishDraftKeyRef.current)) {
createPolishDraftKeyRef.current = null
}
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin || !event.data) {
return
}
const payload = event.data as Partial<PolishWindowResult> & { type?: string }
if (
payload.type !== 'termi-admin-post-polish-apply' ||
typeof payload.draftKey !== 'string' ||
typeof payload.markdown !== 'string'
) {
return
}
const result: PolishWindowResult = {
draftKey: payload.draftKey,
markdown: payload.markdown,
target: payload.target === 'create' ? 'create' : 'editor',
createdAt: typeof payload.createdAt === 'number' ? payload.createdAt : Date.now(),
}
if (!applyExternalPolishResult(result)) {
return
}
consumePolishWindowResult(result.draftKey)
if (result.target === 'editor') {
editorPolishDraftKeyRef.current = null
} else {
createPolishDraftKeyRef.current = null
}
}
const handleStorage = (event: StorageEvent) => {
if (!event.key?.startsWith(POLISH_RESULT_STORAGE_PREFIX)) {
return
}
tryFlushAll()
}
window.addEventListener('message', handleMessage)
window.addEventListener('storage', handleStorage)
window.addEventListener('focus', tryFlushAll)
tryFlushAll()
return () => {
window.removeEventListener('message', handleMessage)
window.removeEventListener('storage', handleStorage)
window.removeEventListener('focus', tryFlushAll)
}
}, [applyExternalPolishResult, flushPendingPolishResult])
const compareStats = useMemo(() => {
if (!editor) {
return {
@@ -1262,67 +1462,143 @@ export function PostsPage() {
}
}, [createForm])
const uploadEditorCover = useCallback(async (file: File) => {
try {
setUploadingEditorCover(true)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '文章封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const localizeEditorMarkdownImages = useCallback(async () => {
if (!editor) {
return
}
const result = await adminApi.uploadMediaObjects([compressed.file], {
prefix: 'post-covers/',
const sourceMarkdown = buildDraftMarkdownForWindow(editor)
if (!stripFrontmatter(sourceMarkdown).trim()) {
toast.error('先准备一点正文,再执行正文图片本地化。')
return
}
try {
setLocalizingEditorImages(true)
const result = await adminApi.localizePostMarkdownImages({
markdown: sourceMarkdown,
prefix: buildInlineImagePrefix(editor.slug),
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
if (!result.localized_count && !result.failed_count) {
toast.message('正文里没有检测到需要本地化的远程图片。')
return
}
startTransition(() => {
setEditor((current) => (current ? { ...current, image: url } : current))
setEditor((current) =>
current ? applyPolishedEditorState(current, result.markdown) : current,
)
})
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingEditorCover(false)
}
}, [])
const uploadCreateCover = useCallback(async (file: File) => {
try {
setUploadingCreateCover(true)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '新建封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
if (result.localized_count && result.failed_count) {
toast.warning(
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
)
} else if (result.localized_count) {
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
} else {
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
} finally {
setLocalizingEditorImages(false)
}
}, [editor])
const result = await adminApi.uploadMediaObjects([compressed.file], {
prefix: 'post-covers/',
const localizeCreateMarkdownImages = useCallback(async () => {
const sourceMarkdown = buildCreateMarkdownForWindow(createForm)
if (!stripFrontmatter(sourceMarkdown).trim()) {
toast.error('先准备一点正文,再执行正文图片本地化。')
return
}
try {
setLocalizingCreateImages(true)
const result = await adminApi.localizePostMarkdownImages({
markdown: sourceMarkdown,
prefix: buildInlineImagePrefix(createForm.slug || createForm.title),
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
if (!result.localized_count && !result.failed_count) {
toast.message('正文里没有检测到需要本地化的远程图片。')
return
}
startTransition(() => {
setCreateForm((current) => ({ ...current, image: url }))
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
})
toast.success('封面已上传并回填。')
if (result.localized_count && result.failed_count) {
toast.warning(
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
)
} else if (result.localized_count) {
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
} else {
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
} finally {
setUploadingCreateCover(false)
setLocalizingCreateImages(false)
}
}, [])
}, [createForm])
const openEditorPreviewWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot()
if (!snapshot) {
toast.error('请先打开一篇文章,再启动独立预览窗口。')
return
}
openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/preview`, snapshot)
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
const openEditorCompareWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot()
if (!snapshot) {
toast.error('请先打开一篇文章,再启动独立对比窗口。')
return
}
openDraftWorkbenchWindow(`/posts/${encodeURIComponent(snapshot.slug)}/compare`, snapshot)
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
const openEditorPolishWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot()
if (!snapshot) {
toast.error('请先打开一篇文章,再启动独立 AI 润色工作台。')
return
}
const draftKey = openDraftWorkbenchWindow('/posts/polish', snapshot, {
target: 'editor',
})
if (draftKey) {
editorPolishDraftKeyRef.current = draftKey
}
}, [buildEditorDraftSnapshot, openDraftWorkbenchWindow])
const openCreatePreviewWindow = useCallback(() => {
openDraftWorkbenchWindow('/posts/preview', buildCreateDraftSnapshot())
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
const openCreateCompareWindow = useCallback(() => {
openDraftWorkbenchWindow('/posts/compare', buildCreateDraftSnapshot())
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
const openCreatePolishWindow = useCallback(() => {
const draftKey = openDraftWorkbenchWindow('/posts/polish', buildCreateDraftSnapshot(), {
target: 'create',
})
if (draftKey) {
createPolishDraftKeyRef.current = draftKey
}
}, [buildCreateDraftSnapshot, openDraftWorkbenchWindow])
const editorPolishHunks = useMemo(
() =>
@@ -1838,32 +2114,6 @@ export function PostsPage() {
void importMarkdownFiles(event.target.files)
}}
/>
<input
ref={editorCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadEditorCover(file)
}
event.currentTarget.value = ''
}}
/>
<input
ref={createCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadCreateCover(file)
}
event.currentTarget.value = ''
}}
/>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
@@ -1877,7 +2127,7 @@ export function PostsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={openCreateDialog}>
<Button variant="outline" onClick={openCreateDialog} data-testid="posts-open-create">
<FilePlus2 className="h-4 w-4" />
稿
</Button>
@@ -1919,6 +2169,7 @@ export function PostsPage() {
<div className="grid gap-3">
<div className="flex flex-col gap-3 lg:flex-row">
<Input
data-testid="posts-search"
className="flex-1"
placeholder="搜索标题、slug、分类、标签或摘要"
value={searchTerm}
@@ -1990,6 +2241,7 @@ export function PostsPage() {
<button
key={post.id}
type="button"
data-testid={`post-item-${post.slug}`}
onClick={() => navigate(`/posts/${post.slug}`)}
className={cn(
'w-full rounded-[1.45rem] border px-4 py-3.5 text-left transition-all',
@@ -2099,7 +2351,7 @@ export function PostsPage() {
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={closeEditorDialog}>
<Button variant="outline" onClick={closeEditorDialog} data-testid="post-editor-close">
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -2148,6 +2400,7 @@ export function PostsPage() {
<CardContent className="space-y-4">
<FormField label="标题">
<Input
data-testid="post-editor-title"
value={editor.title}
onChange={(event) =>
setEditor((current) =>
@@ -2274,29 +2527,34 @@ export function PostsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="封面图 URL">
<Input
value={editor.image}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, image: event.target.value } : current,
)
}
/>
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<div className="space-y-3">
<Input
value={editor.image}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, image: event.target.value } : current,
)
}
/>
<MediaUrlControls
value={editor.image}
onChange={(image) =>
setEditor((current) => (current ? { ...current, image } : current))
}
prefix="post-covers/"
contextLabel="文章封面上传"
mode="cover"
remoteTitle={editor.title || editor.slug || '文章封面'}
dataTestIdPrefix="post-editor-cover"
/>
</div>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => editorCoverInputRef.current?.click()}
disabled={uploadingEditorCover}
>
<Upload className="h-4 w-4" />
{uploadingEditorCover ? '上传中...' : '上传封面'}
</Button>
<Button
variant="outline"
onClick={() => void generateEditorCover()}
disabled={generatingEditorCover || uploadingEditorCover}
disabled={generatingEditorCover}
>
<WandSparkles className="h-4 w-4" />
{generatingEditorCover
@@ -2439,6 +2697,26 @@ export function PostsPage() {
<Bot className="h-4 w-4" />
{generatingEditorMetadataProposal ? '分析中...' : 'AI 元信息'}
</Button>
<Button variant="outline" onClick={openEditorPreviewWindow}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openEditorCompareWindow}>
<GitCompareArrows className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openEditorPolishWindow}>
<WandSparkles className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => void localizeEditorMarkdownImages()}
disabled={saving || localizingEditorImages}
>
<Download className="h-4 w-4" />
{localizingEditorImages ? '本地化中...' : '正文图本地化'}
</Button>
<Button
variant="outline"
onClick={() => {
@@ -2474,11 +2752,12 @@ export function PostsPage() {
<RotateCcw className="h-4 w-4" />
</Button>
<Button onClick={() => void saveEditor()} disabled={saving}>
<Button onClick={() => void saveEditor()} disabled={saving} data-testid="post-editor-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存'}
</Button>
<Button
data-testid="post-editor-delete"
variant="danger"
onClick={async () => {
if (!window.confirm(`确定删除“${editor.title || editor.slug}”吗?`)) {
@@ -2614,6 +2893,7 @@ export function PostsPage() {
<CardContent className="space-y-4">
<FormField label="标题">
<Input
data-testid="post-create-title"
value={createForm.title}
onChange={(event) =>
setCreateForm((current) => ({ ...current, title: event.target.value }))
@@ -2622,6 +2902,7 @@ export function PostsPage() {
</FormField>
<FormField label="Slug" hint="留空则根据标题自动生成。">
<Input
data-testid="post-create-slug"
value={createForm.slug}
onChange={(event) =>
setCreateForm((current) => ({ ...current, slug: event.target.value }))
@@ -2727,27 +3008,32 @@ export function PostsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="封面图 URL">
<Input
value={createForm.image}
onChange={(event) =>
setCreateForm((current) => ({ ...current, image: event.target.value }))
}
/>
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<div className="space-y-3">
<Input
value={createForm.image}
onChange={(event) =>
setCreateForm((current) => ({ ...current, image: event.target.value }))
}
/>
<MediaUrlControls
value={createForm.image}
onChange={(image) =>
setCreateForm((current) => ({ ...current, image }))
}
prefix="post-covers/"
contextLabel="新建文章封面上传"
mode="cover"
remoteTitle={createForm.title || createForm.slug || '文章封面'}
dataTestIdPrefix="post-create-cover"
/>
</div>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => createCoverInputRef.current?.click()}
disabled={uploadingCreateCover}
>
<Upload className="h-4 w-4" />
{uploadingCreateCover ? '上传中...' : '上传封面'}
</Button>
<Button
variant="outline"
onClick={() => void generateCreateCover()}
disabled={generatingCreateCover || uploadingCreateCover}
disabled={generatingCreateCover}
>
<WandSparkles className="h-4 w-4" />
{generatingCreateCover
@@ -2871,6 +3157,26 @@ export function PostsPage() {
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={openCreatePreviewWindow}>
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openCreateCompareWindow}>
<GitCompareArrows className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={openCreatePolishWindow}>
<WandSparkles className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => void localizeCreateMarkdownImages()}
disabled={creating || localizingCreateImages}
>
<Download className="h-4 w-4" />
{localizingCreateImages ? '本地化中...' : '正文图本地化'}
</Button>
<Button
variant="outline"
onClick={() => {
@@ -2907,6 +3213,7 @@ export function PostsPage() {
</Button>
<Button
data-testid="post-create-submit"
onClick={async () => {
if (!createForm.title.trim()) {
toast.error('创建文章时必须填写标题。')

View File

@@ -1,8 +1,9 @@
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -18,10 +19,6 @@ import {
formatReviewType,
reviewTagsToList,
} from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
type ReviewFormState = {
@@ -103,14 +100,12 @@ export function ReviewsPage() {
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [polishingDescription, setPolishingDescription] = useState(false)
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
null,
)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
const loadReviews = useCallback(async (showToast = false) => {
try {
@@ -217,29 +212,6 @@ export function ReviewsPage() {
}
}, [form])
const uploadReviewCover = useCallback(async (file: File) => {
try {
setUploadingCover(true)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '评测封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const result = await adminApi.uploadReviewCoverImage(compressed.file)
startTransition(() => {
setForm((current) => ({ ...current, cover: result.url }))
})
toast.success('评测封面已上传到 R2。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
} finally {
setUploadingCover(false)
}
}, [])
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -305,6 +277,7 @@ export function ReviewsPage() {
<button
key={review.id}
type="button"
data-testid={`review-item-${review.id}`}
onClick={() => {
setSelectedId(review.id)
setForm(toFormState(review))
@@ -363,6 +336,7 @@ export function ReviewsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
data-testid="review-save"
onClick={async () => {
if (!form.title.trim()) {
toast.error('标题不能为空。')
@@ -411,6 +385,7 @@ export function ReviewsPage() {
{selectedReview ? (
<Button
variant="danger"
data-testid="review-delete"
disabled={deleting}
onClick={async () => {
if (!window.confirm('确定删除这条评测吗?')) {
@@ -453,6 +428,7 @@ export function ReviewsPage() {
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="标题">
<Input
data-testid="review-title"
value={form.title}
onChange={(event) =>
setForm((current) => ({ ...current, title: event.target.value }))
@@ -487,6 +463,7 @@ export function ReviewsPage() {
</FormField>
<FormField label="评测日期">
<Input
data-testid="review-date"
type="date"
value={form.reviewDate}
onChange={(event) =>
@@ -508,36 +485,21 @@ export function ReviewsPage() {
</FormField>
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row">
<Input
value={form.cover}
onChange={(event) =>
setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
<input
ref={reviewCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadReviewCover(file)
}
event.target.value = ''
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingCover}
onClick={() => reviewCoverInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
{uploadingCover ? '上传中...' : '上传到 R2'}
</Button>
</div>
<Input
value={form.cover}
onChange={(event) =>
setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
<MediaUrlControls
value={form.cover}
onChange={(cover) => setForm((current) => ({ ...current, cover }))}
prefix="review-covers/"
contextLabel="评测封面上传"
mode="cover"
remoteTitle={form.title || '评测封面'}
dataTestIdPrefix="review-cover"
/>
{form.cover ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
@@ -583,6 +545,7 @@ export function ReviewsPage() {
<Button
size="sm"
variant="outline"
data-testid="review-ai-polish"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
@@ -603,6 +566,7 @@ export function ReviewsPage() {
</div>
<Textarea
data-testid="review-description"
value={form.description}
onChange={(event) => {
const nextDescription = event.target.value
@@ -625,6 +589,7 @@ export function ReviewsPage() {
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
data-testid="review-ai-adopt"
onClick={() => {
setForm((current) => ({
...current,

View File

@@ -248,6 +248,7 @@ export function RevisionsPage() {
<div className="flex flex-wrap items-center gap-3">
<Input
data-testid="revisions-slug-filter"
value={slugFilter}
onChange={(event) => setSlugFilter(event.target.value)}
placeholder="按 slug 过滤,例如 hello-world"
@@ -304,7 +305,12 @@ export function RevisionsPage() {
</TableCell>
<TableCell className="text-muted-foreground">{item.created_at}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
<Button
variant="outline"
size="sm"
onClick={() => void openDetail(item.id)}
data-testid={`revision-open-${item.id}`}
>
<History className="h-4 w-4" />
/
</Button>
@@ -371,6 +377,7 @@ export function RevisionsPage() {
key={mode}
size="sm"
disabled={restoring !== null || !selected.item.has_markdown}
data-testid={`revision-restore-${mode}`}
onClick={() => void runRestore(mode)}
>
<RotateCcw className="h-4 w-4" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
@@ -18,8 +19,11 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { formatBrowserName, formatDateTime } from '@/lib/admin-format'
import { adminApi, ApiError } from '@/lib/api'
import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types'
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger'
const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' },
@@ -71,6 +75,127 @@ function normalizePreview(value: unknown) {
return text || '—'
}
function formatSubscriptionChannelLabel(channelType: string) {
switch (channelType) {
case 'web_push':
return '浏览器提醒'
case 'email':
return '邮件订阅'
case 'discord':
return 'Discord Webhook'
case 'telegram':
return 'Telegram Bot API'
case 'ntfy':
return 'ntfy'
case 'webhook':
return 'Webhook'
default:
return channelType
}
}
function readMetadataString(metadata: SubscriptionRecord['metadata'], key: string) {
const value = metadata?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function formatSubscriptionSource(source: string | null) {
switch (source) {
case 'frontend-popup':
return '前台订阅弹窗'
case 'manual':
return '后台手动添加'
case 'admin':
return '后台手动添加'
case 'import':
return '批量导入'
case 'seed':
return '初始化数据'
default:
return source ?? '未记录'
}
}
function formatSubscriptionPlatform(userAgent: string | null) {
if (!userAgent) {
return null
}
const ua = userAgent.toLowerCase()
if (ua.includes('android')) return 'Android'
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ios')) return 'iOS'
if (ua.includes('windows')) return 'Windows'
if (ua.includes('mac os x') || ua.includes('macintosh')) return 'macOS'
if (ua.includes('linux')) return 'Linux'
return null
}
function formatPushEndpointHost(target: string) {
try {
const url = new URL(target)
return url.host || url.origin
} catch {
return null
}
}
function describeSubscriptionTarget(item: SubscriptionRecord) {
const createdAt = formatDateTime(item.created_at)
if (item.channel_type === 'web_push') {
const userAgent = readMetadataString(item.metadata, 'user_agent')
const browser = userAgent ? formatBrowserName(userAgent) : '浏览器信息未记录'
const platform = formatSubscriptionPlatform(userAgent)
const pushHost = formatPushEndpointHost(item.target)
return {
primary: platform ? `${browser} · ${platform}` : browser,
details: [
pushHost ? `推送节点:${pushHost}` : '推送地址:已隐藏完整链接',
`创建于:${createdAt}`,
],
title: item.target,
}
}
return {
primary: item.target,
details: [`创建于:${createdAt}`],
title: item.target,
}
}
function getSubscriptionSourceBadge(item: SubscriptionRecord): { label: string; variant: BadgeVariant } {
const source = readMetadataString(item.metadata, 'source')
const kind = readMetadataString(item.metadata, 'kind')
if (source === 'frontend-popup') {
return { label: '前台弹窗', variant: 'default' }
}
if (source === 'manual' || source === 'admin') {
return { label: '后台手动', variant: 'secondary' }
}
if (source === 'import' || source === 'seed') {
return { label: formatSubscriptionSource(source), variant: 'warning' }
}
if (kind === 'browser-push') {
return { label: '前台浏览器订阅', variant: 'default' }
}
if (kind === 'public-form') {
return { label: '前台邮箱订阅', variant: 'default' }
}
if (source) {
return { label: formatSubscriptionSource(source), variant: 'outline' }
}
return { label: '未记录来源', variant: 'outline' }
}
export function SubscriptionsPage() {
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
@@ -80,20 +205,29 @@ export function SubscriptionsPage() {
const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null)
const [actioningId, setActioningId] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([])
const [lastActionJobId, setLastActionJobId] = useState<number | null>(null)
const [form, setForm] = useState(emptyForm())
const [subscriptionSearch, setSubscriptionSearch] = useState('')
const [subscriptionChannelFilter, setSubscriptionChannelFilter] = useState('all')
const loadData = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const [nextSubscriptions, nextDeliveries] = await Promise.all([
const [nextSubscriptions, nextDeliveries, nextWorkerJobs] = await Promise.all([
adminApi.listSubscriptions(),
adminApi.listSubscriptionDeliveries(),
adminApi.listWorkerJobs({
workerName: 'worker.notification_delivery',
limit: 200,
}),
])
startTransition(() => {
setSubscriptions(nextSubscriptions)
setDeliveries(nextDeliveries)
setWorkerJobs(nextWorkerJobs.jobs)
})
if (showToast) {
toast.success('订阅中心已刷新。')
@@ -123,6 +257,79 @@ export function SubscriptionsPage() {
[deliveries],
)
const filteredSubscriptions = useMemo(() => {
const query = subscriptionSearch.trim().toLowerCase()
return subscriptions.filter((item) => {
if (subscriptionChannelFilter !== 'all' && item.channel_type !== subscriptionChannelFilter) {
return false
}
if (!query) {
return true
}
const sourceBadge = getSubscriptionSourceBadge(item)
const targetInfo = describeSubscriptionTarget(item)
const searchable = [
item.display_name,
item.target,
item.channel_type,
formatSubscriptionChannelLabel(item.channel_type),
sourceBadge.label,
targetInfo.primary,
...targetInfo.details,
readMetadataString(item.metadata, 'user_agent'),
readMetadataString(item.metadata, 'source'),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchable.includes(query)
})
}, [subscriptionChannelFilter, subscriptionSearch, subscriptions])
const groupedSubscriptions = useMemo(
() => [
{
key: 'web_push',
title: '浏览器提醒',
description: '默认主流程,授权后可直接收到站内更新提醒。',
badgeVariant: 'default' as BadgeVariant,
items: filteredSubscriptions.filter((item) => item.channel_type === 'web_push'),
},
{
key: 'email',
title: '邮件订阅',
description: '通常作为额外备份,确认邮箱后开始生效。',
badgeVariant: 'secondary' as BadgeVariant,
items: filteredSubscriptions.filter((item) => item.channel_type === 'email'),
},
{
key: 'other',
title: '其他渠道',
description: 'Webhook / Discord / Telegram / ntfy 等外部通知目标。',
badgeVariant: 'outline' as BadgeVariant,
items: filteredSubscriptions.filter(
(item) => item.channel_type !== 'web_push' && item.channel_type !== 'email',
),
},
].filter((group) => group.items.length > 0),
[filteredSubscriptions],
)
const deliveryJobMap = useMemo(() => {
const map = new Map<number, WorkerJobRecord>()
for (const item of workerJobs) {
const relatedId = Number.parseInt(String(item.related_entity_id || ''), 10)
if (Number.isFinite(relatedId) && !map.has(relatedId)) {
map.set(relatedId, item)
}
}
return map
}, [workerJobs])
const resetForm = useCallback(() => {
setEditingId(null)
setForm(emptyForm())
@@ -158,6 +365,132 @@ export function SubscriptionsPage() {
}
}, [editingId, form, loadData, resetForm])
const renderSubscriptionRow = useCallback((item: SubscriptionRecord) => {
const targetInfo = describeSubscriptionTarget(item)
const sourceBadge = getSubscriptionSourceBadge(item)
return (
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{item.display_name ?? formatSubscriptionChannelLabel(item.channel_type)}
</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{item.channel_type}
</div>
</div>
</TableCell>
<TableCell className="max-w-[320px] break-words text-sm text-muted-foreground">
<div className="space-y-2" title={targetInfo.title}>
<div className="font-medium text-foreground">{targetInfo.primary}</div>
<div className="flex flex-wrap gap-2">
<Badge variant={sourceBadge.variant}>{sourceBadge.label}</Badge>
{item.channel_type === 'web_push' ? <Badge variant="outline"></Badge> : null}
</div>
{targetInfo.details.map((line) => (
<div key={line} className="text-xs text-muted-foreground/80">
{line}
</div>
))}
<div className="text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
{item.status}
</Badge>
<div className="text-xs text-muted-foreground">
{item.failure_count ?? 0} · {item.last_delivery_status ?? '—'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
{normalizePreview(item.filters)}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
data-testid={`subscription-edit-${item.id}`}
onClick={() => {
setEditingId(item.id)
setForm({
channelType: item.channel_type,
target: item.target,
displayName: item.display_name ?? '',
status: item.status,
notes: item.notes ?? '',
filtersText: prettyJson(item.filters),
metadataText: prettyJson(item.metadata),
})
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-test-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
const result = await adminApi.testSubscription(item.id)
if (result.job_id) {
setLastActionJobId(result.job_id)
}
toast.success(
result.job_id
? `测试通知已入队:#${result.job_id}`
: '测试通知已入队。',
)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
} finally {
setActioningId(null)
}
}}
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-delete-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.deleteSubscription(item.id)
toast.success('订阅目标已删除。')
if (editingId === item.id) {
resetForm()
}
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除失败。')
} finally {
setActioningId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
}, [actioningId, editingId, loadData, resetForm])
if (loading) {
return (
<div className="space-y-6">
@@ -188,11 +521,13 @@ export function SubscriptionsPage() {
<Button
variant="secondary"
disabled={digesting !== null}
data-testid="subscriptions-send-weekly"
onClick={async () => {
try {
setDigesting('weekly')
const result = await adminApi.sendSubscriptionDigest('weekly')
toast.success(`周报已入队queued ${result.queued}skipped ${result.skipped}`)
const result = await adminApi.runDigestWorker('weekly')
setLastActionJobId(result.job.id)
toast.success(`周报任务已入队:#${result.job.id}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
@@ -206,11 +541,13 @@ export function SubscriptionsPage() {
</Button>
<Button
disabled={digesting !== null}
data-testid="subscriptions-send-monthly"
onClick={async () => {
try {
setDigesting('monthly')
const result = await adminApi.sendSubscriptionDigest('monthly')
toast.success(`月报已入队queued ${result.queued}skipped ${result.skipped}`)
const result = await adminApi.runDigestWorker('monthly')
setLastActionJobId(result.job.id)
toast.success(`月报任务已入队:#${result.job.id}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
@@ -222,6 +559,11 @@ export function SubscriptionsPage() {
<BellRing className="h-4 w-4" />
{digesting === 'monthly' ? '入队中...' : '发送月报'}
</Button>
{lastActionJobId ? (
<Button variant="outline" asChild data-testid="subscriptions-last-job">
<Link to={`/workers?job=${lastActionJobId}`}></Link>
</Button>
) : null}
</div>
</div>
@@ -314,7 +656,12 @@ export function SubscriptionsPage() {
/>
</div>
<div className="flex flex-wrap gap-3">
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
<Button
className="flex-1"
disabled={submitting}
onClick={() => void submitForm()}
data-testid="subscriptions-save"
>
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
</Button>
@@ -332,121 +679,91 @@ export function SubscriptionsPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> filters / metadata</CardDescription>
<CardDescription> / / </CardDescription>
</div>
<Badge variant="outline">{subscriptions.length} </Badge>
<Badge variant="outline">
{filteredSubscriptions.length} / {subscriptions.length}
</Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{item.channel_type}
</div>
<CardContent className="space-y-4">
<div className="grid gap-4 rounded-2xl border border-border/70 bg-background/50 p-4 md:grid-cols-[minmax(0,1.2fr)_220px_auto] md:items-end">
<div className="space-y-2">
<Label></Label>
<Input
value={subscriptionSearch}
onChange={(event) => setSubscriptionSearch(event.target.value)}
placeholder="搜索名称、地址、来源、浏览器、推送节点..."
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={subscriptionChannelFilter}
onChange={(event) => setSubscriptionChannelFilter(event.target.value)}
>
<option value="all"></option>
{CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{formatSubscriptionChannelLabel(item.value)}
</option>
))}
</Select>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
{(subscriptionSearch.trim() || subscriptionChannelFilter !== 'all') ? (
<Button
variant="outline"
size="sm"
onClick={() => {
setSubscriptionSearch('')
setSubscriptionChannelFilter('all')
}}
>
</Button>
) : null}
</div>
</div>
{subscriptions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : groupedSubscriptions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
groupedSubscriptions.map((group) => (
<div
key={group.key}
className="overflow-hidden rounded-2xl border border-border/70 bg-background/35"
>
<div className="flex flex-col gap-3 border-b border-border/60 px-4 py-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{group.title}</h3>
<Badge variant={group.badgeVariant}>{group.items.length} </Badge>
</div>
</TableCell>
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
<div>{item.target}</div>
<div className="mt-1 text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
{item.status}
</Badge>
<div className="text-xs text-muted-foreground">
{item.failure_count ?? 0} · {item.last_delivery_status ?? '—'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
{normalizePreview(item.filters)}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingId(item.id)
setForm({
channelType: item.channel_type,
target: item.target,
displayName: item.display_name ?? '',
status: item.status,
notes: item.notes ?? '',
filtersText: prettyJson(item.filters),
metadataText: prettyJson(item.metadata),
})
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={actioningId === item.id}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.testSubscription(item.id)
toast.success('测试通知已入队。')
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
} finally {
setActioningId(null)
}
}}
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={actioningId === item.id}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.deleteSubscription(item.id)
toast.success('订阅目标已删除。')
if (editingId === item.id) {
resetForm()
}
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除失败。')
} finally {
setActioningId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-sm text-muted-foreground">{group.description}</p>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>{group.items.map(renderSubscriptionRow)}</TableBody>
</Table>
</div>
))
)}
</CardContent>
</Card>
</div>
@@ -468,11 +785,14 @@ export function SubscriptionsPage() {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Worker</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map((item) => (
{deliveries.map((item) => {
const workerJob = deliveryJobMap.get(item.id)
return (
<TableRow key={item.id}>
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
<TableCell>
@@ -494,11 +814,26 @@ export function SubscriptionsPage() {
<div>attempts: {item.attempts_count}</div>
<div>next: {item.next_retry_at ?? '—'}</div>
</TableCell>
<TableCell>
{workerJob ? (
<Button
variant="outline"
size="sm"
asChild
data-testid={`subscription-delivery-job-${item.id}`}
>
<Link to={`/workers?job=${workerJob.id}`}>#{workerJob.id}</Link>
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
{item.response_text ?? '—'}
</TableCell>
</TableRow>
))}
)
})}
</TableBody>
</Table>
</CardContent>

View File

@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -218,6 +219,7 @@ export function TagsPage() {
</CardHeader>
<CardContent className="space-y-4">
<Input
data-testid="tags-search"
placeholder="按标签名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
@@ -229,6 +231,7 @@ export function TagsPage() {
<button
key={item.id}
type="button"
data-testid={`tag-item-${item.slug}`}
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
@@ -286,6 +289,7 @@ export function TagsPage() {
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="标签名称" hint="例如astro、rust、workflow。">
<Input
data-testid="tag-name-input"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="输入标签名称"
@@ -293,19 +297,32 @@ export function TagsPage() {
</FormField>
<FormField label="标签 slug" hint="留空时自动从英文名称生成;中文建议手填。">
<Input
data-testid="tag-slug-input"
value={form.slug}
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
placeholder="astro"
/>
</FormField>
<FormField label="封面图 URL" hint="可选,用于前台标签头图。">
<Input
value={form.coverImage}
onChange={(event) =>
setForm((current) => ({ ...current, coverImage: event.target.value }))
}
placeholder="https://cdn.example.com/covers/astro.jpg"
/>
<FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<div className="space-y-3">
<Input
value={form.coverImage}
onChange={(event) =>
setForm((current) => ({ ...current, coverImage: event.target.value }))
}
placeholder="https://cdn.example.com/covers/astro.jpg"
/>
<MediaUrlControls
value={form.coverImage}
onChange={(coverImage) =>
setForm((current) => ({ ...current, coverImage }))
}
prefix="tag-covers/"
contextLabel="标签封面上传"
remoteTitle={form.name || form.slug || '标签封面'}
dataTestIdPrefix="tag-cover"
/>
</div>
</FormField>
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
<div className="flex items-center gap-3">
@@ -377,7 +394,7 @@ export function TagsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
<Button onClick={() => void handleSave()} disabled={saving} data-testid="tag-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
</Button>
@@ -388,6 +405,7 @@ export function TagsPage() {
variant="ghost"
onClick={() => void handleDelete()}
disabled={!selectedItem || deleting}
data-testid="tag-delete"
className="text-rose-600 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />

File diff suppressed because it is too large Load Diff

2
backend/Cargo.lock generated
View File

@@ -6972,6 +6972,7 @@ dependencies = [
"base64 0.22.1",
"chrono",
"fastembed",
"image",
"include_dir",
"insta",
"loco-rs",
@@ -6984,6 +6985,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"tokio",
"tower-http",
"tracing",

View File

@@ -43,9 +43,11 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
fastembed = "5.1"
async-stream = "0.3"
base64 = "0.22"
image = { version = "0.25.10", default-features = false, features = ["avif", "gif", "jpeg", "png", "webp"] }
aws-config = "1"
aws-sdk-s3 = "1"
web-push = { version = "0.11.0", default-features = false, features = ["hyper-client"] }
sha2 = "0.10"
[[bin]]
name = "termi_api-cli"

View File

@@ -1,7 +1,8 @@
# syntax=docker/dockerfile:1.7
FROM rust:1.94-trixie AS chef
RUN cargo install cargo-chef --locked
FROM rust:1.94.1-trixie AS chef
RUN rustup component add rustfmt clippy \
&& cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
@@ -9,11 +10,20 @@ COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
ENV CARGO_HOME=/usr/local/cargo \
CARGO_TARGET_DIR=/app/.cargo-target
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --locked --recipe-path recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
cargo chef cook --release --locked --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked --bin termi_api-cli
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
cargo build --release --locked --bin termi_api-cli \
&& install -Dm755 /app/.cargo-target/release/termi_api-cli /tmp/termi_api-cli
FROM debian:trixie-slim AS runtime
RUN apt-get update \
@@ -21,7 +31,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/termi_api-cli /usr/local/bin/termi_api-cli
COPY --from=builder /tmp/termi_api-cli /usr/local/bin/termi_api-cli
COPY --from=builder /app/config ./config
COPY --from=builder /app/assets ./assets
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh

View File

@@ -5,13 +5,15 @@ Loco.rs backend当前仅保留 API 与后台鉴权相关逻辑,不再提供
## 本地启动
```powershell
cargo loco start
cargo loco start --server-and-worker
```
默认本地监听:
- `http://localhost:5150`
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
## 当前职责
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API

View File

@@ -2,35 +2,35 @@
pid: 1
author: "林川"
email: "linchuan@example.com"
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。"
content: "这篇读起来很稳,段落密度和古文节奏都很舒服。"
approved: true
- id: 2
pid: 1
author: "阿青"
email: "aqing@example.com"
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。"
content: "建议后面再加几篇山水游记,读者会更容易比较不同山名与路线。"
approved: true
- id: 3
pid: 2
author: "周宁"
email: "zhouling@example.com"
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。"
content: "这一段关于南岩和琼台的描写很好,细节很有画面感。"
approved: true
- id: 4
pid: 3
author: "顾远"
email: "guyuan@example.com"
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
content: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。"
approved: true
- id: 5
pid: 4
author: "清嘉"
email: "qingjia@example.com"
content: "黄山记的序文很适合测试首屏摘要生成。"
content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。"
approved: true
- id: 6

View File

@@ -10,7 +10,7 @@
自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。"
excerpt: "《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。"
category: "古籍游记"
published: true
pinned: true
@@ -18,7 +18,7 @@
- 徐霞客
- 游记
- 太和山
- 长文测试
- 山水游记
- id: 2
pid: 2
@@ -40,7 +40,7 @@
- 徐霞客
- 游记
- 太和山
- 长文测试
- 山水游记
- id: 3
pid: 3
@@ -54,7 +54,7 @@
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。"
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。"
category: "古籍游记"
published: true
pinned: false
@@ -62,7 +62,7 @@
- 徐霞客
- 恒山
- 悬空寺
- 长文测试
- 山水游记
- id: 4
pid: 4
@@ -84,7 +84,7 @@
- 钱谦益
- 黄山
- 游记
- 长文测试
- 山水游记
- id: 5
pid: 5
@@ -98,7 +98,7 @@
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。"
excerpt: "钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。"
category: "古籍游记"
published: true
pinned: false
@@ -106,4 +106,4 @@
- 钱谦益
- 黄山
- 游记
- 长文测试
- 山水游记

View File

@@ -34,7 +34,7 @@
rating: 5
review_date: "2024-02-18"
status: "published"
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。"
description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。"
tags: ["经济", "非虚构", "中国"]
cover: "/review-covers/placed-within.svg"

View File

@@ -2,13 +2,13 @@
site_name: "InitCool"
site_short_name: "Termi"
site_url: "https://init.cool"
site_title: "InitCool · 中文长文与 AI 搜索实验站"
site_description: "一个偏终端审美的中文内容站用来测试文章检索、AI 问答、段落评论与后台工作流。"
hero_title: "欢迎来到我的中文内容实验站"
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
site_title: "InitCool · 技术笔记与内容档案"
site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
owner_title: "负责把脑洞拧成页面的人"
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool"
social_twitter: ""
@@ -43,6 +43,9 @@
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
accent_color: "#375a7f"
description: "节奏更明显一点,适合切换阅读状态。"
music_enabled: true
maintenance_mode_enabled: false
maintenance_access_code: null
ai_enabled: false
paragraph_comments_enabled: true
comment_verification_mode: "captcha"
@@ -57,3 +60,4 @@
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
ai_top_k: 4
ai_chunk_size: 1200
seo_favicon_url: null

View File

@@ -12,7 +12,7 @@ tags:
- 徐霞客
- 游记
- 太和山
- 长文测试
- 山水游记
---
# 徐霞客游记·游太和山日记(下)

View File

@@ -1,7 +1,7 @@
---
title: 游黄山记(中)
slug: loco-rs-framework
description: 钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点
description: 钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔
category: 古籍游记
post_type: article
pinned: false
@@ -12,7 +12,7 @@ tags:
- 钱谦益
- 黄山
- 游记
- 长文测试
- 山水游记
---
# 游黄山记(中)

View File

@@ -1,7 +1,7 @@
---
title: 徐霞客游记·游恒山日记
slug: rust-programming-tips
description: 游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试
description: 游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明
category: 古籍游记
post_type: article
pinned: false
@@ -12,7 +12,7 @@ tags:
- 徐霞客
- 恒山
- 悬空寺
- 长文测试
- 山水游记
---
# 徐霞客游记·游恒山日记

View File

@@ -12,7 +12,7 @@ tags:
- 钱谦益
- 黄山
- 游记
- 长文测试
- 山水游记
---
# 游黄山记(上)

View File

@@ -1,7 +1,7 @@
---
title: 徐霞客游记·游太和山日记(上)
slug: welcome-to-termi
description: 《徐霞客游记》太和山上篇,适合作为中文长文测试样本
description: 《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读
category: 古籍游记
post_type: article
pinned: true
@@ -12,7 +12,7 @@ tags:
- 徐霞客
- 游记
- 太和山
- 长文测试
- 山水游记
---
# 徐霞客游记·游太和山日记(上)

View File

@@ -43,6 +43,11 @@ mod m20260401_000032_add_runtime_security_keys_to_site_settings;
mod m20260401_000033_add_taxonomy_metadata_and_media_assets;
mod m20260401_000034_add_source_markdown_to_posts;
mod m20260401_000035_add_human_verification_modes_to_site_settings;
mod m20260402_000036_create_worker_jobs;
mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings;
mod m20260402_000038_add_music_enabled_to_site_settings;
mod m20260402_000039_add_maintenance_mode_to_site_settings;
mod m20260403_000040_add_favicon_url_to_site_settings;
pub struct Migrator;
#[async_trait::async_trait]
@@ -90,6 +95,11 @@ impl MigratorTrait for Migrator {
Box::new(m20260401_000033_add_taxonomy_metadata_and_media_assets::Migration),
Box::new(m20260401_000034_add_source_markdown_to_posts::Migration),
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
Box::new(m20260402_000036_create_worker_jobs::Migration),
Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration),
Box::new(m20260402_000038_add_music_enabled_to_site_settings::Migration),
Box::new(m20260402_000039_add_maintenance_mode_to_site_settings::Migration),
Box::new(m20260403_000040_add_favicon_url_to_site_settings::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,98 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_table(
manager,
"worker_jobs",
&[
("id", ColType::PkAuto),
("parent_job_id", ColType::IntegerNull),
("job_kind", ColType::String),
("worker_name", ColType::String),
("display_name", ColType::StringNull),
("status", ColType::String),
("queue_name", ColType::StringNull),
("requested_by", ColType::StringNull),
("requested_source", ColType::StringNull),
("trigger_mode", ColType::StringNull),
("payload", ColType::JsonBinaryNull),
("result", ColType::JsonBinaryNull),
("error_text", ColType::TextNull),
("tags", ColType::JsonBinaryNull),
("related_entity_type", ColType::StringNull),
("related_entity_id", ColType::StringNull),
("attempts_count", ColType::Integer),
("max_attempts", ColType::Integer),
("cancel_requested", ColType::Boolean),
("queued_at", ColType::StringNull),
("started_at", ColType::StringNull),
("finished_at", ColType::StringNull),
],
&[],
)
.await?;
for (name, columns) in [
(
"idx_worker_jobs_status_created_at",
vec![Alias::new("status"), Alias::new("created_at")],
),
(
"idx_worker_jobs_worker_status_created_at",
vec![
Alias::new("worker_name"),
Alias::new("status"),
Alias::new("created_at"),
],
),
(
"idx_worker_jobs_kind_created_at",
vec![Alias::new("job_kind"), Alias::new("created_at")],
),
(
"idx_worker_jobs_related_entity",
vec![Alias::new("related_entity_type"), Alias::new("related_entity_id")],
),
(
"idx_worker_jobs_parent_job_id",
vec![Alias::new("parent_job_id")],
),
] {
let mut statement = Index::create();
statement.name(name).table(Alias::new("worker_jobs"));
for column in columns {
statement.col(column);
}
manager.create_index(statement.to_owned()).await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
for index_name in [
"idx_worker_jobs_parent_job_id",
"idx_worker_jobs_related_entity",
"idx_worker_jobs_kind_created_at",
"idx_worker_jobs_worker_status_created_at",
"idx_worker_jobs_status_created_at",
] {
manager
.drop_index(
Index::drop()
.name(index_name)
.table(Alias::new("worker_jobs"))
.to_owned(),
)
.await?;
}
drop_table(manager, "worker_jobs").await
}
}

View File

@@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager
.has_column("site_settings", "seo_wechat_share_qr_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("seo_wechat_share_qr_enabled"))
.boolean()
.null()
.default(false),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager
.has_column("site_settings", "seo_wechat_share_qr_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("seo_wechat_share_qr_enabled"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,46 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager.has_column("site_settings", "music_enabled").await? {
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("music_enabled"))
.boolean()
.null()
.default(true),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager.has_column("site_settings", "music_enabled").await? {
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("music_enabled"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,84 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager
.has_column("site_settings", "maintenance_mode_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("maintenance_mode_enabled"))
.boolean()
.null()
.default(false),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "maintenance_access_code")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.add_column(
ColumnDef::new(Alias::new("maintenance_access_code"))
.text()
.null(),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager
.has_column("site_settings", "maintenance_access_code")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.drop_column(Alias::new("maintenance_access_code"))
.to_owned(),
)
.await?;
}
if manager
.has_column("site_settings", "maintenance_mode_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("maintenance_mode_enabled"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,45 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager.has_column("site_settings", "seo_favicon_url").await? {
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("seo_favicon_url"))
.string()
.null(),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager.has_column("site_settings", "seo_favicon_url").await? {
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("seo_favicon_url"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -1,18 +1,18 @@
use async_trait::async_trait;
use axum::{
http::{header, HeaderName, Method},
Router as AxumRouter,
http::{HeaderName, Method, header},
};
use loco_rs::{
Result,
app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue},
boot::{create_app, BootResult, StartMode},
boot::{BootResult, StartMode, create_app},
config::Config,
controller::AppRoutes,
db::{self, truncate_table},
environment::Environment,
task::Tasks,
Result,
};
use migration::Migrator;
use sea_orm::{
@@ -28,7 +28,10 @@ use crate::{
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
},
tasks,
workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker},
workers::{
ai_reindex::AiReindexWorker, downloader::DownloadWorker,
notification_delivery::NotificationDeliveryWorker,
},
};
pub struct App;
@@ -99,7 +102,9 @@ impl Hooks for App {
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![Box::new(initializers::content_sync::ContentSyncInitializer)])
Ok(vec![Box::new(
initializers::content_sync::ContentSyncInitializer,
)])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
@@ -151,8 +156,11 @@ impl Hooks for App {
Ok(router.layer(cors))
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(AiReindexWorker::build(ctx)).await?;
queue.register(DownloadWorker::build(ctx)).await?;
queue.register(NotificationDeliveryWorker::build(ctx)).await?;
queue
.register(NotificationDeliveryWorker::build(ctx))
.await?;
Ok(())
}
@@ -334,8 +342,7 @@ impl Hooks for App {
let comment_verification_mode = settings["comment_verification_mode"]
.as_str()
.map(ToString::to_string);
let subscription_verification_mode = settings
["subscription_verification_mode"]
let subscription_verification_mode = settings["subscription_verification_mode"]
.as_str()
.map(ToString::to_string);
let comment_turnstile_enabled = settings["comment_turnstile_enabled"]
@@ -343,8 +350,7 @@ impl Hooks for App {
.or(comment_verification_mode
.as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile")));
let subscription_turnstile_enabled = settings
["subscription_turnstile_enabled"]
let subscription_turnstile_enabled = settings["subscription_turnstile_enabled"]
.as_bool()
.or(subscription_verification_mode
.as_deref()
@@ -381,6 +387,28 @@ impl Hooks for App {
})
.filter(|items| !items.is_empty())
.map(serde_json::Value::Array);
let music_enabled = settings["music_enabled"].as_bool().or(Some(true));
let maintenance_mode_enabled = settings["maintenance_mode_enabled"]
.as_bool()
.or(Some(false));
let maintenance_access_code = settings["maintenance_access_code"]
.as_str()
.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let seo_favicon_url = settings["seo_favicon_url"].as_str().and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let item = site_settings::ActiveModel {
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
@@ -422,6 +450,10 @@ impl Hooks for App {
location: Set(settings["location"].as_str().map(ToString::to_string)),
tech_stack: Set(tech_stack),
music_playlist: Set(music_playlist),
music_enabled: Set(music_enabled),
maintenance_mode_enabled: Set(maintenance_mode_enabled),
maintenance_access_code: Set(maintenance_access_code),
seo_favicon_url: Set(seo_favicon_url),
ai_enabled: Set(settings["ai_enabled"].as_bool()),
paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"]
.as_bool()

View File

@@ -1,4 +1,4 @@
use axum::http::{header, HeaderMap};
use axum::http::{HeaderMap, header};
use loco_rs::prelude::*;
use serde::Serialize;
use std::{
@@ -75,7 +75,8 @@ fn header_value(headers: &HeaderMap, key: &'static str) -> Option<String> {
}
fn split_groups(value: Option<String>) -> Vec<String> {
value.unwrap_or_default()
value
.unwrap_or_default()
.split([',', ';', ' '])
.map(str::trim)
.filter(|item| !item.is_empty())
@@ -192,8 +193,7 @@ pub(crate) fn resolve_admin_identity(headers: &HeaderMap) -> Option<AdminIdentit
}
pub(crate) fn check_auth(headers: &HeaderMap) -> Result<AdminIdentity> {
resolve_admin_identity(headers)
.ok_or_else(|| Error::Unauthorized("Not logged in".to_string()))
resolve_admin_identity(headers).ok_or_else(|| Error::Unauthorized("Not logged in".to_string()))
}
pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) {

View File

@@ -1,8 +1,12 @@
use std::collections::{HashMap, HashSet};
use axum::{
extract::{Multipart, Query},
http::{HeaderMap, header},
};
use loco_rs::prelude::*;
use regex::Regex;
use reqwest::Url;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set,
@@ -22,7 +26,10 @@ use crate::{
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
reviews,
},
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
services::{
admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs,
},
workers::downloader::{DownloadWorkerArgs, download_media_to_storage, normalize_target_format},
};
#[derive(Clone, Debug, Deserialize)]
@@ -168,6 +175,9 @@ pub struct AdminSiteSettingsResponse {
pub location: Option<String>,
pub tech_stack: Vec<String>,
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
pub music_enabled: bool,
pub maintenance_mode_enabled: bool,
pub maintenance_access_code: Option<String>,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
@@ -203,8 +213,10 @@ pub struct AdminSiteSettingsResponse {
pub media_r2_public_base_url: Option<String>,
pub media_r2_access_key_id: Option<String>,
pub media_r2_secret_access_key: Option<String>,
pub seo_favicon_url: Option<String>,
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: bool,
pub notification_webhook_url: Option<String>,
pub notification_channel_type: String,
pub notification_comment_enabled: bool,
@@ -218,8 +230,8 @@ pub struct AdminSiteSettingsResponse {
#[derive(Clone, Debug, Serialize)]
pub struct AdminAiReindexResponse {
pub indexed_chunks: usize,
pub last_indexed_at: Option<String>,
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
#[derive(Clone, Debug, Deserialize)]
@@ -346,6 +358,38 @@ pub struct AdminMediaMetadataResponse {
pub notes: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDownloadPayload {
pub source_url: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub target_format: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default)]
pub caption: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub sync: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDownloadResponse {
pub queued: bool,
pub job_id: Option<i32>,
pub status: Option<String>,
pub key: Option<String>,
pub url: Option<String>,
pub size_bytes: Option<i64>,
pub content_type: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery {
pub prefix: Option<String>,
@@ -459,6 +503,37 @@ pub struct AdminPostPolishRequest {
pub markdown: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostLocalizeImagesRequest {
pub markdown: String,
#[serde(default)]
pub prefix: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizedImageItem {
pub source_url: String,
pub localized_url: String,
pub key: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesFailure {
pub source_url: String,
pub error: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesResponse {
pub markdown: String,
pub detected_count: usize,
pub localized_count: usize,
pub uploaded_count: usize,
pub failed_count: usize,
pub items: Vec<AdminPostLocalizedImageItem>,
pub failures: Vec<AdminPostLocalizeImagesFailure>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AdminReviewPolishRequest {
pub title: String,
@@ -509,6 +584,199 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
})
}
fn normalize_localize_image_prefix(value: Option<String>) -> String {
trim_to_option(value)
.map(|item| item.trim_matches('/').to_string())
.filter(|item| !item.is_empty())
.unwrap_or_else(|| "post-inline-images".to_string())
}
fn normalize_markdown_image_target(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('<') && trimmed.ends_with('>') && trimmed.len() > 2 {
Some(trimmed[1..trimmed.len() - 1].trim().to_string())
} else {
Some(trimmed.to_string())
}
}
fn markdown_image_reference_urls(markdown: &str) -> Vec<String> {
let markdown_pattern =
Regex::new(r#"!\[[^\]]*]\((?P<url><[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)"#)
.expect("valid markdown image regex");
let html_double_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*"(?P<url>[^"]+)""#)
.expect("valid html img double quote regex");
let html_single_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*'(?P<url>[^']+)'"#)
.expect("valid html img single quote regex");
let mut urls = Vec::new();
for captures in markdown_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
for captures in html_double_quote_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
for captures in html_single_quote_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
urls
}
fn is_remote_markdown_image_candidate(
url: &str,
settings: Option<&storage::MediaStorageSettings>,
) -> bool {
let Ok(parsed) = Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
if settings
.and_then(|item| storage::object_key_from_public_url(item, url))
.is_some()
{
return false;
}
true
}
fn replace_markdown_image_urls(
markdown: &str,
replacements: &HashMap<String, String>,
) -> (String, usize) {
let markdown_pattern = Regex::new(
r#"(?P<lead>!\[[^\]]*]\()(?P<url><[^>\n]+>|[^)\s]+)(?P<trail>(?:\s+(?:"[^"]*"|'[^']*'))?\))"#,
)
.expect("valid markdown image replacement regex");
let html_double_quote_pattern =
Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*")(?P<url>[^"]+)(?P<trail>"[^>]*>)"#)
.expect("valid html img double quote replacement regex");
let html_single_quote_pattern =
Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*')(?P<url>[^']+)(?P<trail>'[^>]*>)"#)
.expect("valid html img single quote replacement regex");
let mut localized_count = 0usize;
let after_markdown = markdown_pattern
.replace_all(markdown, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
let after_html_double = html_double_quote_pattern
.replace_all(&after_markdown, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
let after_html_single = html_single_quote_pattern
.replace_all(&after_html_double, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
(after_html_single, localized_count)
}
fn parse_optional_timestamp(
value: Option<&str>,
) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>> {
@@ -757,6 +1025,9 @@ fn build_settings_response(
location: item.location,
tech_stack: tech_stack_values(&item.tech_stack),
music_playlist: music_playlist_values(&item.music_playlist),
music_enabled: item.music_enabled.unwrap_or(true),
maintenance_mode_enabled: item.maintenance_mode_enabled.unwrap_or(false),
maintenance_access_code: item.maintenance_access_code,
ai_enabled: item.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -798,8 +1069,10 @@ fn build_settings_response(
media_r2_public_base_url: item.media_r2_public_base_url,
media_r2_access_key_id: item.media_r2_access_key_id,
media_r2_secret_access_key: item.media_r2_secret_access_key,
seo_favicon_url: item.seo_favicon_url,
seo_default_og_image: item.seo_default_og_image,
seo_default_twitter_handle: item.seo_default_twitter_handle,
seo_wechat_share_qr_enabled: item.seo_wechat_share_qr_enabled.unwrap_or(false),
notification_webhook_url: item.notification_webhook_url,
notification_channel_type: item
.notification_channel_type
@@ -1122,16 +1395,28 @@ pub async fn update_site_settings(
#[debug_handler]
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
let summary = ai::rebuild_index(&ctx).await?;
let actor = check_auth(&headers)?;
let job = worker_jobs::queue_ai_reindex_job(
&ctx,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
format::json(AdminAiReindexResponse {
indexed_chunks: summary.indexed_chunks,
last_indexed_at: format_timestamp(
summary.last_indexed_at.map(Into::into),
"%Y-%m-%d %H:%M:%S UTC",
),
})
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.ai_reindex",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
None,
)
.await?;
format::json(AdminAiReindexResponse { queued: true, job })
}
#[debug_handler]
@@ -1457,6 +1742,94 @@ pub async fn replace_media_object(
})
}
#[debug_handler]
pub async fn download_media_object(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminMediaDownloadPayload>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let target_format = normalize_target_format(payload.target_format.clone())?;
let worker_args = DownloadWorkerArgs {
source_url: payload.source_url.clone(),
prefix: payload.prefix.clone(),
target_format,
title: payload.title.clone(),
alt_text: payload.alt_text.clone(),
caption: payload.caption.clone(),
tags: payload.tags.unwrap_or_default(),
notes: payload.notes.clone(),
job_id: None,
};
if payload.sync {
let downloaded = download_media_to_storage(&ctx, &worker_args).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"media.download",
"media",
Some(downloaded.key.clone()),
Some(payload.source_url.clone()),
Some(serde_json::json!({
"queued": false,
"source_url": payload.source_url,
"target_format": worker_args.target_format,
"key": downloaded.key,
"url": downloaded.url,
})),
)
.await?;
return format::json(AdminMediaDownloadResponse {
queued: false,
job_id: None,
status: Some("completed".to_string()),
key: Some(downloaded.key),
url: Some(downloaded.url),
size_bytes: Some(downloaded.size_bytes),
content_type: downloaded.content_type,
});
}
let job = worker_jobs::queue_download_job(
&ctx,
&worker_args,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"media.download",
"media",
Some(job.id.to_string()),
Some(payload.source_url.clone()),
Some(serde_json::json!({
"job_id": job.id,
"queued": true,
"source_url": payload.source_url,
"target_format": worker_args.target_format,
})),
)
.await?;
format::json(AdminMediaDownloadResponse {
queued: true,
job_id: Some(job.id),
status: Some(job.status),
key: None,
url: None,
size_bytes: None,
content_type: None,
})
}
#[debug_handler]
pub async fn list_comment_blacklist(
headers: HeaderMap,
@@ -1829,6 +2202,89 @@ pub async fn polish_post_markdown(
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
}
#[debug_handler]
pub async fn localize_post_markdown_images(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminPostLocalizeImagesRequest>,
) -> Result<Response> {
check_auth(&headers)?;
let normalized_markdown = payload.markdown.replace("\r\n", "\n");
let prefix = normalize_localize_image_prefix(payload.prefix);
let settings = storage::optional_r2_settings(&ctx).await?;
let detected_urls = markdown_image_reference_urls(&normalized_markdown);
let candidate_urls = detected_urls
.into_iter()
.filter(|url| is_remote_markdown_image_candidate(url, settings.as_ref()))
.collect::<Vec<_>>();
if candidate_urls.is_empty() {
return format::json(AdminPostLocalizeImagesResponse {
markdown: normalized_markdown,
detected_count: 0,
localized_count: 0,
uploaded_count: 0,
failed_count: 0,
items: Vec::new(),
failures: Vec::new(),
});
}
let mut seen = HashSet::new();
let unique_urls = candidate_urls
.iter()
.filter(|url| seen.insert((*url).clone()))
.cloned()
.collect::<Vec<_>>();
let mut replacements = HashMap::<String, String>::new();
let mut items = Vec::<AdminPostLocalizedImageItem>::new();
let mut failures = Vec::<AdminPostLocalizeImagesFailure>::new();
for source_url in unique_urls {
let args = DownloadWorkerArgs {
source_url: source_url.clone(),
prefix: Some(prefix.clone()),
target_format: None,
title: None,
alt_text: None,
caption: None,
tags: vec!["markdown-image".to_string()],
notes: Some("localized from markdown body".to_string()),
job_id: None,
};
match download_media_to_storage(&ctx, &args).await {
Ok(downloaded) => {
replacements.insert(source_url.clone(), downloaded.url.clone());
items.push(AdminPostLocalizedImageItem {
source_url,
localized_url: downloaded.url,
key: downloaded.key,
});
}
Err(error) => failures.push(AdminPostLocalizeImagesFailure {
source_url,
error: error.to_string(),
}),
}
}
let (markdown, localized_count) =
replace_markdown_image_urls(&normalized_markdown, &replacements);
format::json(AdminPostLocalizeImagesResponse {
markdown,
detected_count: candidate_urls.len(),
localized_count,
uploaded_count: items.len(),
failed_count: failures.len(),
items,
failures,
})
}
#[debug_handler]
pub async fn polish_review_description(
headers: HeaderMap,
@@ -1967,6 +2423,10 @@ pub fn routes() -> Routes {
.add("/ai/reindex", post(reindex_ai))
.add("/ai/test-provider", post(test_ai_provider))
.add("/ai/test-image-provider", post(test_ai_image_provider))
.add(
"/posts/localize-images",
post(localize_post_markdown_images),
)
.add("/storage/r2/test", post(test_r2_storage))
.add(
"/storage/media",
@@ -1982,6 +2442,7 @@ pub fn routes() -> Routes {
"/storage/media/metadata",
patch(update_media_object_metadata),
)
.add("/storage/media/download", post(download_media_object))
.add("/storage/media/replace", post(replace_media_object))
.add(
"/comments/blacklist",

View File

@@ -8,12 +8,10 @@ use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
models::_entities::{
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
},
models::_entities::{admin_audit_logs, notification_deliveries, post_revisions, subscriptions},
services::{
admin_audit, backups, post_revisions as revision_service,
subscriptions as subscription_service,
subscriptions as subscription_service, worker_jobs,
},
};
@@ -35,6 +33,15 @@ pub struct DeliveriesQuery {
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorkerJobsQuery {
pub status: Option<String>,
pub job_kind: Option<String>,
pub worker_name: Option<String>,
pub search: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionPayload {
#[serde(alias = "channelType")]
@@ -85,6 +92,11 @@ pub struct DigestDispatchRequest {
pub period: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct RetryDeliveriesRequest {
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SiteBackupImportRequest {
pub backup: backups::SiteBackupDocument,
@@ -132,6 +144,12 @@ pub struct DeliveryListResponse {
pub deliveries: Vec<notification_deliveries::Model>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerTaskActionResponse {
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
@@ -154,7 +172,12 @@ fn format_revision(item: post_revisions::Model) -> PostRevisionListItem {
actor_email: item.actor_email,
actor_source: item.actor_source,
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(),
has_markdown: item
.markdown
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some(),
metadata: item.metadata,
}
}
@@ -167,17 +190,31 @@ pub async fn list_audit_logs(
) -> Result<Response> {
check_auth(&headers)?;
let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
let mut db_query =
admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
if let Some(action) = query
.action
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action));
}
if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
if let Some(target_type) = query
.target_type
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type));
}
format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?)
format::json(
db_query
.limit(query.limit.unwrap_or(80))
.all(&ctx.db)
.await?,
)
}
#[debug_handler]
@@ -187,7 +224,9 @@ pub async fn list_post_revisions(
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?;
let items =
revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120))
.await?;
format::json(items.into_iter().map(format_revision).collect::<Vec<_>>())
}
@@ -214,8 +253,7 @@ pub async fn restore_post_revision(
) -> Result<Response> {
let actor = check_auth(&headers)?;
let mode = payload.mode.unwrap_or_else(|| "full".to_string());
let restored =
revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
let restored = revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
@@ -258,7 +296,8 @@ pub async fn list_subscription_deliveries(
) -> Result<Response> {
check_auth(&headers)?;
format::json(DeliveryListResponse {
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?,
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80))
.await?,
})
}
@@ -280,7 +319,9 @@ pub async fn create_subscription(
channel_type: Set(channel_type.clone()),
target: Set(target.clone()),
display_name: Set(trim_to_option(payload.display_name)),
status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))),
status: Set(subscription_service::normalize_status(
payload.status.as_deref().unwrap_or("active"),
)),
filters: Set(subscription_service::normalize_filters(payload.filters)),
metadata: Set(payload.metadata),
secret: Set(trim_to_option(payload.secret)),
@@ -408,6 +449,13 @@ pub async fn test_subscription(
.ok_or(Error::NotFound)?;
let delivery = subscription_service::send_test_notification(&ctx, &item).await?;
let job = worker_jobs::find_latest_job_by_related_entity(
&ctx,
"notification_delivery",
&delivery.id.to_string(),
Some(worker_jobs::WORKER_NOTIFICATION_DELIVERY),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
@@ -419,7 +467,12 @@ pub async fn test_subscription(
)
.await?;
format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id }))
format::json(serde_json::json!({
"queued": true,
"id": item.id,
"delivery_id": delivery.id,
"job_id": job.as_ref().map(|value| value.id),
}))
}
#[debug_handler]
@@ -429,7 +482,9 @@ pub async fn send_subscription_digest(
Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?;
let summary =
subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly"))
.await?;
admin_audit::log_event(
&ctx,
@@ -450,6 +505,162 @@ pub async fn send_subscription_digest(
format::json(summary)
}
#[debug_handler]
pub async fn workers_overview(
headers: HeaderMap,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(worker_jobs::get_overview(&ctx).await?)
}
#[debug_handler]
pub async fn list_worker_jobs(
headers: HeaderMap,
Query(query): Query<WorkerJobsQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(
worker_jobs::list_jobs(
&ctx,
worker_jobs::WorkerJobListQuery {
status: query.status,
job_kind: query.job_kind,
worker_name: query.worker_name,
search: query.search,
limit: query.limit,
},
)
.await?,
)
}
#[debug_handler]
pub async fn get_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(worker_jobs::get_job_record(&ctx, id).await?)
}
#[debug_handler]
pub async fn cancel_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let updated = worker_jobs::request_cancel(&ctx, id).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.cancel",
"worker_job",
Some(id.to_string()),
Some(updated.worker_name.clone()),
Some(serde_json::json!({ "status": updated.status })),
)
.await?;
format::json(updated)
}
#[debug_handler]
pub async fn retry_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let job = worker_jobs::retry_job(
&ctx,
id,
Some(actor.username.clone()),
Some(actor.source.clone()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.retry",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "source_job_id": id })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn run_retry_deliveries_job(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<RetryDeliveriesRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let job = worker_jobs::spawn_retry_deliveries_task(
&ctx,
payload.limit,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.task.retry_deliveries",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "limit": payload.limit })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn run_digest_worker_job(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let period = payload.period.unwrap_or_else(|| "weekly".to_string());
let job = worker_jobs::spawn_digest_task(
&ctx,
&period,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.task.digest",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "period": period })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn export_site_backup(
headers: HeaderMap,
@@ -476,11 +687,30 @@ pub fn routes() -> Routes {
.add("/post-revisions", get(list_post_revisions))
.add("/post-revisions/{id}", get(get_post_revision))
.add("/post-revisions/{id}/restore", post(restore_post_revision))
.add("/subscriptions", get(list_subscriptions).post(create_subscription))
.add("/subscriptions/deliveries", get(list_subscription_deliveries))
.add(
"/subscriptions",
get(list_subscriptions).post(create_subscription),
)
.add(
"/subscriptions/deliveries",
get(list_subscription_deliveries),
)
.add("/subscriptions/digest", post(send_subscription_digest))
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
.add(
"/subscriptions/{id}",
patch(update_subscription).delete(delete_subscription),
)
.add("/subscriptions/{id}/test", post(test_subscription))
.add("/workers/overview", get(workers_overview))
.add("/workers/jobs", get(list_worker_jobs))
.add("/workers/jobs/{id}", get(get_worker_job))
.add("/workers/jobs/{id}/cancel", post(cancel_worker_job))
.add("/workers/jobs/{id}/retry", post(retry_worker_job))
.add(
"/workers/tasks/retry-deliveries",
post(run_retry_deliveries_job),
)
.add("/workers/tasks/digest", post(run_digest_worker_job))
.add("/site-backup/export", get(export_site_backup))
.add("/site-backup/import", post(import_site_backup))
}

View File

@@ -4,8 +4,8 @@ use async_stream::stream;
use axum::{
body::{Body, Bytes},
http::{
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
HeaderMap, HeaderValue,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
},
};
use chrono::{DateTime, Utc};
@@ -16,7 +16,7 @@ use std::time::Instant;
use crate::{
controllers::{admin::check_auth, site_settings},
services::{abuse_guard, ai, analytics},
services::{abuse_guard, ai, analytics, worker_jobs},
};
#[derive(Clone, Debug, Deserialize)]
@@ -35,8 +35,8 @@ pub struct AskResponse {
#[derive(Clone, Debug, Serialize)]
pub struct ReindexResponse {
pub indexed_chunks: usize,
pub last_indexed_at: Option<String>,
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
#[derive(Clone, Debug, Serialize)]
@@ -514,13 +514,17 @@ pub async fn ask_stream(
#[debug_handler]
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
check_auth(&headers)?;
let summary = ai::rebuild_index(&ctx).await?;
let actor = check_auth(&headers)?;
let job = worker_jobs::queue_ai_reindex_job(
&ctx,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
format::json(ReindexResponse {
indexed_chunks: summary.indexed_chunks,
last_indexed_at: format_timestamp(summary.last_indexed_at),
})
format::json(ReindexResponse { queued: true, job })
}
pub fn routes() -> Routes {

View File

@@ -8,10 +8,11 @@ use std::collections::BTreeMap;
use std::net::SocketAddr;
use axum::{
extract::{rejection::ExtensionRejection, ConnectInfo},
http::{header, HeaderMap},
extract::{ConnectInfo, rejection::ExtensionRejection},
http::{HeaderMap, header},
};
use crate::controllers::admin::check_auth;
use crate::models::_entities::{
comments::{ActiveModel, Column, Entity, Model},
posts,
@@ -21,7 +22,6 @@ use crate::services::{
comment_guard::{self, CommentGuardInput},
notifications,
};
use crate::controllers::admin::check_auth;
const ARTICLE_SCOPE: &str = "article";
const PARAGRAPH_SCOPE: &str = "paragraph";

View File

@@ -38,8 +38,15 @@ pub async fn record(
headers: HeaderMap,
Json(payload): Json<ContentAnalyticsEventPayload>,
) -> Result<Response> {
let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers);
if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() {
let mut request_context =
analytics::content_request_context_from_headers(&payload.path, &headers);
if payload
.referrer
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
request_context.referrer = payload.referrer;
}

View File

@@ -127,7 +127,9 @@ pub async fn update(
"friend_link.update",
"friend_link",
Some(item.id.to_string()),
item.site_name.clone().or_else(|| Some(item.site_url.clone())),
item.site_name
.clone()
.or_else(|| Some(item.site_url.clone())),
Some(serde_json::json!({ "status": item.status })),
)
.await?;
@@ -142,7 +144,10 @@ pub async fn remove(
) -> Result<Response> {
let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?;
let label = item.site_name.clone().or_else(|| Some(item.site_url.clone()));
let label = item
.site_name
.clone()
.or_else(|| Some(item.site_url.clone()));
item.delete(&ctx.db).await?;
admin_audit::log_event(
&ctx,

View File

@@ -1,12 +1,12 @@
pub mod admin;
pub mod admin_api;
pub mod admin_taxonomy;
pub mod admin_ops;
pub mod admin_taxonomy;
pub mod ai;
pub mod auth;
pub mod content_analytics;
pub mod category;
pub mod comment;
pub mod content_analytics;
pub mod friend_link;
pub mod health;
pub mod post;

View File

@@ -7,12 +7,23 @@ use sea_orm::{EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use crate::{
controllers::admin::check_auth,
controllers::admin::{check_auth, resolve_admin_identity},
models::_entities::reviews::{self, Entity as ReviewEntity},
services::{admin_audit, storage},
};
#[derive(Serialize, Deserialize, Debug)]
fn is_public_review_status(status: Option<&str>) -> bool {
matches!(
status
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"published" | "completed" | "done"
)
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CreateReviewRequest {
pub title: String,
pub review_type: String,
@@ -25,7 +36,7 @@ pub struct CreateReviewRequest {
pub link_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UpdateReviewRequest {
pub title: Option<String>,
pub review_type: Option<String>,
@@ -38,23 +49,32 @@ pub struct UpdateReviewRequest {
pub link_url: Option<String>,
}
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
pub async fn list(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
let include_private = resolve_admin_identity(&headers).is_some();
let reviews = ReviewEntity::find()
.order_by_desc(reviews::Column::CreatedAt)
.all(&ctx.db)
.await?;
.await?
.into_iter()
.filter(|review| include_private || is_public_review_status(review.status.as_deref()))
.collect::<Vec<_>>();
format::json(reviews)
}
pub async fn get_one(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<impl IntoResponse> {
let include_private = resolve_admin_identity(&headers).is_some();
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
match review {
Some(r) => format::json(r),
Some(r) if include_private || is_public_review_status(r.status.as_deref()) => {
format::json(r)
}
Some(_) => Err(Error::NotFound),
None => Err(Error::NotFound),
}
}

View File

@@ -6,6 +6,7 @@ use axum::http::HeaderMap;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use uuid::Uuid;
@@ -89,6 +90,12 @@ pub struct SiteSettingsPayload {
pub tech_stack: Option<Vec<String>>,
#[serde(default, alias = "musicPlaylist")]
pub music_playlist: Option<Vec<MusicTrackPayload>>,
#[serde(default, alias = "musicEnabled")]
pub music_enabled: Option<bool>,
#[serde(default, alias = "maintenanceModeEnabled")]
pub maintenance_mode_enabled: Option<bool>,
#[serde(default, alias = "maintenanceAccessCode")]
pub maintenance_access_code: Option<String>,
#[serde(default, alias = "aiEnabled")]
pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")]
@@ -153,10 +160,14 @@ pub struct SiteSettingsPayload {
pub media_r2_access_key_id: Option<String>,
#[serde(default, alias = "mediaR2SecretAccessKey")]
pub media_r2_secret_access_key: Option<String>,
#[serde(default, alias = "seoFaviconUrl")]
pub seo_favicon_url: Option<String>,
#[serde(default, alias = "seoDefaultOgImage")]
pub seo_default_og_image: Option<String>,
#[serde(default, alias = "seoDefaultTwitterHandle")]
pub seo_default_twitter_handle: Option<String>,
#[serde(default, alias = "seoWechatShareQrEnabled")]
pub seo_wechat_share_qr_enabled: Option<bool>,
#[serde(default, alias = "notificationWebhookUrl")]
pub notification_webhook_url: Option<String>,
#[serde(default, alias = "notificationChannelType")]
@@ -197,6 +208,7 @@ pub struct PublicSiteSettingsResponse {
pub location: Option<String>,
pub tech_stack: Option<serde_json::Value>,
pub music_playlist: Option<serde_json::Value>,
pub music_enabled: bool,
pub ai_enabled: bool,
pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String,
@@ -210,8 +222,35 @@ pub struct PublicSiteSettingsResponse {
pub subscription_popup_title: String,
pub subscription_popup_description: String,
pub subscription_popup_delay_seconds: i32,
pub seo_favicon_url: Option<String>,
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceAccessTokenPayload {
#[serde(default, alias = "accessToken")]
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceVerifyPayload {
#[serde(default)]
pub code: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceAccessStatusResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceVerifyResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
@@ -249,6 +288,51 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
value.map(|item| item.clamp(min, max))
}
fn maintenance_mode_enabled(model: &Model) -> bool {
model.maintenance_mode_enabled.unwrap_or(false)
}
fn maintenance_access_code(model: &Model) -> Option<String> {
normalize_optional_string(model.maintenance_access_code.clone())
}
fn maintenance_access_token_from_secret(secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"termi-maintenance-access:v1:");
hasher.update(secret.as_bytes());
let digest = hasher.finalize();
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn validate_maintenance_access_token(model: &Model, token: Option<&str>) -> bool {
let Some(candidate) = token.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}) else {
return false;
};
let Some(secret) = maintenance_access_code(model) else {
return false;
};
candidate == maintenance_access_token_from_secret(&secret)
}
fn verify_maintenance_access_code(model: &Model, code: Option<&str>) -> Option<String> {
let candidate = code.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})?;
let secret = maintenance_access_code(model)?;
(candidate == secret).then(|| maintenance_access_token_from_secret(&secret))
}
fn normalize_notification_channel_type(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let normalized = item.trim().to_ascii_lowercase();
@@ -269,7 +353,7 @@ pub(crate) fn default_subscription_popup_title() -> String {
}
pub(crate) fn default_subscription_popup_description() -> String {
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订".to_string()
"有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份".to_string()
}
pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
@@ -552,6 +636,15 @@ impl SiteSettingsPayload {
if let Some(music_playlist) = self.music_playlist {
item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist)));
}
if let Some(music_enabled) = self.music_enabled {
item.music_enabled = Some(music_enabled);
}
if let Some(maintenance_mode_enabled) = self.maintenance_mode_enabled {
item.maintenance_mode_enabled = Some(maintenance_mode_enabled);
}
if self.maintenance_access_code.is_some() {
item.maintenance_access_code = normalize_optional_string(self.maintenance_access_code);
}
if let Some(ai_enabled) = self.ai_enabled {
item.ai_enabled = Some(ai_enabled);
}
@@ -686,6 +779,9 @@ impl SiteSettingsPayload {
item.media_r2_secret_access_key =
normalize_optional_string(Some(media_r2_secret_access_key));
}
if let Some(seo_favicon_url) = self.seo_favicon_url {
item.seo_favicon_url = normalize_optional_string(Some(seo_favicon_url));
}
if let Some(seo_default_og_image) = self.seo_default_og_image {
item.seo_default_og_image = normalize_optional_string(Some(seo_default_og_image));
}
@@ -693,6 +789,9 @@ impl SiteSettingsPayload {
item.seo_default_twitter_handle =
normalize_optional_string(Some(seo_default_twitter_handle));
}
if let Some(seo_wechat_share_qr_enabled) = self.seo_wechat_share_qr_enabled {
item.seo_wechat_share_qr_enabled = Some(seo_wechat_share_qr_enabled);
}
if let Some(notification_webhook_url) = self.notification_webhook_url {
item.notification_webhook_url =
normalize_optional_string(Some(notification_webhook_url));
@@ -746,10 +845,10 @@ fn default_payload() -> SiteSettingsPayload {
site_name: Some("InitCool".to_string()),
site_short_name: Some("Termi".to_string()),
site_url: Some("https://init.cool".to_string()),
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活".to_string()),
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
site_title: Some("InitCool · 技术笔记与内容档案".to_string()),
site_description: Some("围绕开发实践、产品观察与长期积累整理的中文内容站".to_string()),
hero_title: Some("欢迎来到 InitCool".to_string()),
hero_subtitle: Some("记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。".to_string()),
owner_name: Some("InitCool".to_string()),
owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()),
owner_bio: Some(
@@ -807,6 +906,9 @@ fn default_payload() -> SiteSettingsPayload {
description: Some("节奏更明显一点,适合切换阅读状态。".to_string()),
},
]),
music_enabled: Some(true),
maintenance_mode_enabled: Some(false),
maintenance_access_code: None,
ai_enabled: Some(false),
paragraph_comments_enabled: Some(true),
comment_verification_mode: Some(
@@ -846,8 +948,10 @@ fn default_payload() -> SiteSettingsPayload {
media_r2_public_base_url: None,
media_r2_access_key_id: None,
media_r2_secret_access_key: None,
seo_favicon_url: None,
seo_default_og_image: None,
seo_default_twitter_handle: None,
seo_wechat_share_qr_enabled: Some(false),
notification_webhook_url: None,
notification_channel_type: Some("webhook".to_string()),
notification_comment_enabled: Some(false),
@@ -916,6 +1020,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
location: model.location,
tech_stack: model.tech_stack,
music_playlist: model.music_playlist,
music_enabled: model.music_enabled.unwrap_or(true),
ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -943,8 +1048,10 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
subscription_popup_delay_seconds: model
.subscription_popup_delay_seconds
.unwrap_or_else(default_subscription_popup_delay_seconds),
seo_favicon_url: model.seo_favicon_url,
seo_default_og_image: model.seo_default_og_image,
seo_default_twitter_handle: model.seo_default_twitter_handle,
seo_wechat_share_qr_enabled: model.seo_wechat_share_qr_enabled.unwrap_or(false),
}
}
@@ -1011,6 +1118,50 @@ pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
format::json(public_response(load_current(&ctx).await?))
}
#[debug_handler]
pub async fn maintenance_status(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceAccessTokenPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
let access_granted = if enabled {
validate_maintenance_access_token(&current, params.access_token.as_deref())
} else {
true
};
format::json(MaintenanceAccessStatusResponse {
maintenance_mode_enabled: enabled,
access_granted,
})
}
#[debug_handler]
pub async fn maintenance_verify(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceVerifyPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
if !enabled {
return format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: false,
access_granted: true,
access_token: None,
});
}
let access_token = verify_maintenance_access_code(&current, params.code.as_deref());
format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: true,
access_granted: access_token.is_some(),
access_token,
})
}
#[debug_handler]
pub async fn update(
headers: HeaderMap,
@@ -1031,6 +1182,8 @@ pub fn routes() -> Routes {
Routes::new()
.prefix("api/site_settings/")
.add("home", get(home))
.add("maintenance/status", post(maintenance_status))
.add("maintenance/verify", post(maintenance_verify))
.add("/", get(show))
.add("/", put(update))
.add("/", patch(update))

View File

@@ -33,6 +33,26 @@ pub struct PublicBrowserPushSubscriptionPayload {
pub captcha_answer: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PublicCombinedSubscriptionPayload {
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub subscription: Option<serde_json::Value>,
#[serde(default)]
pub source: Option<String>,
#[serde(default, alias = "turnstileToken")]
pub turnstile_token: Option<String>,
#[serde(default, alias = "captchaToken")]
pub captcha_token: Option<String>,
#[serde(default, alias = "captchaAnswer")]
pub captcha_answer: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionTokenPayload {
pub token: String,
@@ -63,6 +83,21 @@ pub struct PublicSubscriptionResponse {
pub message: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PublicCombinedSubscriptionItemResponse {
pub channel_type: String,
pub subscription_id: i32,
pub status: String,
pub requires_confirmation: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct PublicCombinedSubscriptionResponse {
pub ok: bool,
pub channels: Vec<PublicCombinedSubscriptionItemResponse>,
pub message: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct SubscriptionManageResponse {
pub ok: bool,
@@ -89,6 +124,30 @@ fn public_browser_push_metadata(
})
}
fn normalize_public_subscription_channels(channels: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for raw in channels {
let Some(channel) = ({
match raw.trim().to_ascii_lowercase().as_str() {
"email" | "mail" => Some("email"),
"browser" | "browser-push" | "browser_push" | "webpush" | "web-push" => {
Some("browser_push")
}
_ => None,
}
}) else {
continue;
};
if !normalized.iter().any(|value| value == channel) {
normalized.push(channel.to_string());
}
}
normalized
}
async fn verify_subscription_human_check(
settings: &crate::models::_entities::site_settings::Model,
turnstile_token: Option<&str>,
@@ -119,11 +178,7 @@ pub async fn subscribe(
) -> Result<Response> {
let email = payload.email.trim().to_ascii_lowercase();
let client_ip = abuse_guard::detect_client_ip(&headers);
abuse_guard::enforce_public_scope(
"subscription",
client_ip.as_deref(),
Some(&email),
)?;
abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(&email))?;
let settings = crate::controllers::site_settings::load_current(&ctx).await?;
verify_subscription_human_check(
&settings,
@@ -186,7 +241,9 @@ pub async fn subscribe_browser_push(
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()))?
.ok_or_else(|| {
Error::BadRequest("browser push subscription.endpoint 不能为空".to_string())
})?
.to_string();
let client_ip = abuse_guard::detect_client_ip(&headers);
let user_agent = headers
@@ -196,15 +253,11 @@ pub async fn subscribe_browser_push(
.filter(|value| !value.is_empty())
.map(ToString::to_string);
abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?;
verify_subscription_human_check(
&settings,
payload.turnstile_token.as_deref(),
payload.captcha_token.as_deref(),
payload.captcha_answer.as_deref(),
abuse_guard::enforce_public_scope(
"browser-push-subscription",
client_ip.as_deref(),
)
.await?;
Some(&endpoint),
)?;
let result = subscriptions::create_public_web_push_subscription(
&ctx,
@@ -240,6 +293,174 @@ pub async fn subscribe_browser_push(
})
}
#[debug_handler]
pub async fn subscribe_combined(
State(ctx): State<AppContext>,
headers: axum::http::HeaderMap,
Json(payload): Json<PublicCombinedSubscriptionPayload>,
) -> Result<Response> {
let selected_channels = normalize_public_subscription_channels(&payload.channels);
if selected_channels.is_empty() {
return Err(Error::BadRequest("请至少选择一种订阅方式".to_string()));
}
let wants_email = selected_channels.iter().any(|value| value == "email");
let wants_browser_push = selected_channels
.iter()
.any(|value| value == "browser_push");
let settings = crate::controllers::site_settings::load_current(&ctx).await?;
let client_ip = abuse_guard::detect_client_ip(&headers);
let normalized_email = payload
.email
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase());
if wants_email {
let email = normalized_email
.as_deref()
.ok_or_else(|| Error::BadRequest("请选择邮箱订阅后填写邮箱地址".to_string()))?;
abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(email))?;
}
let normalized_browser_subscription = if wants_browser_push {
if !crate::services::web_push::is_enabled(&settings) {
return Err(Error::BadRequest("浏览器推送未启用".to_string()));
}
let subscription = payload
.subscription
.clone()
.ok_or_else(|| Error::BadRequest("缺少浏览器推送订阅信息".to_string()))?;
let endpoint = subscription
.get("endpoint")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
Error::BadRequest("browser push subscription.endpoint 不能为空".to_string())
})?
.to_string();
abuse_guard::enforce_public_scope(
"browser-push-subscription",
client_ip.as_deref(),
Some(&endpoint),
)?;
Some(subscription)
} else {
None
};
if wants_email {
verify_subscription_human_check(
&settings,
payload.turnstile_token.as_deref(),
payload.captcha_token.as_deref(),
payload.captcha_answer.as_deref(),
client_ip.as_deref(),
)
.await?;
}
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let mut items = Vec::new();
let mut message_parts = Vec::new();
if let Some(subscription) = normalized_browser_subscription {
let browser_result = subscriptions::create_public_web_push_subscription(
&ctx,
subscription.clone(),
Some(public_browser_push_metadata(
payload.source.clone(),
subscription,
user_agent,
)),
)
.await?;
admin_audit::log_event(
&ctx,
None,
"subscription.public.web_push.active",
"subscription",
Some(browser_result.subscription.id.to_string()),
Some(browser_result.subscription.target.clone()),
Some(serde_json::json!({
"channel_type": browser_result.subscription.channel_type,
"status": browser_result.subscription.status,
})),
)
.await?;
message_parts.push(browser_result.message.clone());
items.push(PublicCombinedSubscriptionItemResponse {
channel_type: browser_result.subscription.channel_type,
subscription_id: browser_result.subscription.id,
status: browser_result.subscription.status,
requires_confirmation: false,
});
}
if wants_email {
let email_result = subscriptions::create_public_email_subscription(
&ctx,
normalized_email.as_deref().unwrap_or_default(),
payload.display_name,
Some(public_subscription_metadata(payload.source)),
)
.await?;
admin_audit::log_event(
&ctx,
None,
if email_result.requires_confirmation {
"subscription.public.pending"
} else {
"subscription.public.active"
},
"subscription",
Some(email_result.subscription.id.to_string()),
Some(email_result.subscription.target.clone()),
Some(serde_json::json!({
"channel_type": email_result.subscription.channel_type,
"status": email_result.subscription.status,
})),
)
.await?;
message_parts.push(email_result.message.clone());
items.push(PublicCombinedSubscriptionItemResponse {
channel_type: email_result.subscription.channel_type,
subscription_id: email_result.subscription.id,
status: email_result.subscription.status,
requires_confirmation: email_result.requires_confirmation,
});
}
let message = if message_parts.is_empty() {
"订阅请求已处理。".to_string()
} else {
message_parts.join(" ")
};
format::json(PublicCombinedSubscriptionResponse {
ok: true,
channels: items,
message,
})
}
#[debug_handler]
pub async fn confirm(
State(ctx): State<AppContext>,
@@ -333,6 +554,7 @@ pub fn routes() -> Routes {
Routes::new()
.prefix("/api/subscriptions")
.add("/", post(subscribe))
.add("/combined", post(subscribe_combined))
.add("/browser-push", post(subscribe_browser_push))
.add("/confirm", post(confirm))
.add("/manage", get(manage).patch(update_manage))

View File

@@ -2,35 +2,35 @@
pid: 1
author: "林川"
email: "linchuan@example.com"
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。"
content: "这篇读起来很稳,段落密度和古文节奏都很舒服。"
approved: true
- id: 2
pid: 1
author: "阿青"
email: "aqing@example.com"
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。"
content: "建议后面再加几篇山水游记,读者会更容易比较不同山名与路线。"
approved: true
- id: 3
pid: 2
author: "周宁"
email: "zhouling@example.com"
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。"
content: "这一段关于南岩和琼台的描写很好,细节很有画面感。"
approved: true
- id: 4
pid: 3
author: "顾远"
email: "guyuan@example.com"
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
content: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。"
approved: true
- id: 5
pid: 4
author: "清嘉"
email: "qingjia@example.com"
content: "黄山记的序文很适合测试首屏摘要生成。"
content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。"
approved: true
- id: 6

View File

@@ -10,7 +10,7 @@
自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。"
excerpt: "《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。"
category: "古籍游记"
published: true
pinned: true
@@ -18,7 +18,7 @@
- 徐霞客
- 游记
- 太和山
- 长文测试
- 山水游记
- id: 2
pid: 2
@@ -40,7 +40,7 @@
- 徐霞客
- 游记
- 太和山
- 长文测试
- 山水游记
- id: 3
pid: 3
@@ -54,7 +54,7 @@
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。"
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。"
category: "古籍游记"
published: true
pinned: false
@@ -62,7 +62,7 @@
- 徐霞客
- 恒山
- 悬空寺
- 长文测试
- 山水游记
- id: 4
pid: 4
@@ -84,7 +84,7 @@
- 钱谦益
- 黄山
- 游记
- 长文测试
- 山水游记
- id: 5
pid: 5
@@ -98,7 +98,7 @@
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。"
excerpt: "钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。"
category: "古籍游记"
published: true
pinned: false
@@ -106,4 +106,4 @@
- 钱谦益
- 黄山
- 游记
- 长文测试
- 山水游记

View File

@@ -34,7 +34,7 @@
rating: 5
review_date: "2024-02-18"
status: "published"
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。"
description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。"
tags: ["经济", "非虚构", "中国"]
cover: "/review-covers/placed-within.svg"

View File

@@ -2,13 +2,13 @@
site_name: "InitCool"
site_short_name: "Termi"
site_url: "https://init.cool"
site_title: "InitCool · 中文长文与 AI 搜索实验站"
site_description: "一个偏终端审美的中文内容站用来测试文章检索、AI 问答、段落评论与后台工作流。"
hero_title: "欢迎来到我的中文内容实验站"
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
site_title: "InitCool · 技术笔记与内容档案"
site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
owner_title: "负责把脑洞拧成页面的人"
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool"
social_twitter: ""
@@ -43,6 +43,9 @@
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
accent_color: "#375a7f"
description: "节奏更明显一点,适合切换阅读状态。"
music_enabled: true
maintenance_mode_enabled: false
maintenance_access_code: null
ai_enabled: false
paragraph_comments_enabled: true
comment_verification_mode: "captcha"
@@ -57,3 +60,4 @@
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
ai_top_k: 4
ai_chunk_size: 1200
seo_favicon_url: null

View File

@@ -108,19 +108,25 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
})
.filter(|items| !items.is_empty())
.map(serde_json::Value::Array);
let music_enabled = seed["music_enabled"].as_bool().or(Some(true));
let maintenance_mode_enabled = seed["maintenance_mode_enabled"].as_bool().or(Some(false));
let maintenance_access_code = as_optional_string(&seed["maintenance_access_code"]);
let seo_favicon_url = as_optional_string(&seed["seo_favicon_url"]);
let comment_verification_mode = as_optional_string(&seed["comment_verification_mode"]);
let subscription_verification_mode =
as_optional_string(&seed["subscription_verification_mode"]);
let comment_turnstile_enabled = seed["comment_turnstile_enabled"]
.as_bool()
.or(comment_verification_mode
.as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile")));
let subscription_turnstile_enabled = seed["subscription_turnstile_enabled"]
.as_bool()
.or(subscription_verification_mode
.as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile")));
let comment_turnstile_enabled =
seed["comment_turnstile_enabled"]
.as_bool()
.or(comment_verification_mode
.as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile")));
let subscription_turnstile_enabled =
seed["subscription_turnstile_enabled"]
.as_bool()
.or(subscription_verification_mode
.as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile")));
let existing = site_settings::Entity::find()
.order_by_asc(site_settings::Column::Id)
@@ -182,6 +188,18 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
if existing.music_playlist.is_none() {
model.music_playlist = Set(music_playlist);
}
if existing.music_enabled.is_none() {
model.music_enabled = Set(music_enabled);
}
if existing.maintenance_mode_enabled.is_none() {
model.maintenance_mode_enabled = Set(maintenance_mode_enabled);
}
if is_blank(&existing.maintenance_access_code) {
model.maintenance_access_code = Set(maintenance_access_code.clone());
}
if is_blank(&existing.seo_favicon_url) {
model.seo_favicon_url = Set(seo_favicon_url.clone());
}
if existing.ai_enabled.is_none() {
model.ai_enabled = Set(seed["ai_enabled"].as_bool());
}
@@ -261,6 +279,10 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
location: Set(as_optional_string(&seed["location"])),
tech_stack: Set(tech_stack),
music_playlist: Set(music_playlist),
music_enabled: Set(music_enabled),
maintenance_mode_enabled: Set(maintenance_mode_enabled),
maintenance_access_code: Set(maintenance_access_code),
seo_favicon_url: Set(seo_favicon_url),
ai_enabled: Set(seed["ai_enabled"].as_bool()),
paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"]
.as_bool()

View File

@@ -20,3 +20,4 @@ pub mod site_settings;
pub mod subscriptions;
pub mod tags;
pub mod users;
pub mod worker_jobs;

View File

@@ -1,7 +1,7 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
pub use super::ai_chunks::Entity as AiChunks;
pub use super::admin_audit_logs::Entity as AdminAuditLogs;
pub use super::ai_chunks::Entity as AiChunks;
pub use super::categories::Entity as Categories;
pub use super::comment_blacklist::Entity as CommentBlacklist;
pub use super::comment_persona_analysis_logs::Entity as CommentPersonaAnalysisLogs;
@@ -18,3 +18,4 @@ pub use super::site_settings::Entity as SiteSettings;
pub use super::subscriptions::Entity as Subscriptions;
pub use super::tags::Entity as Tags;
pub use super::users::Entity as Users;
pub use super::worker_jobs::Entity as WorkerJobs;

View File

@@ -30,6 +30,10 @@ pub struct Model {
pub tech_stack: Option<Json>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub music_playlist: Option<Json>,
pub music_enabled: Option<bool>,
pub maintenance_mode_enabled: Option<bool>,
#[sea_orm(column_type = "Text", nullable)]
pub maintenance_access_code: Option<String>,
pub ai_enabled: Option<bool>,
pub paragraph_comments_enabled: Option<bool>,
pub comment_turnstile_enabled: Option<bool>,
@@ -74,8 +78,11 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub media_r2_secret_access_key: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub seo_favicon_url: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: Option<bool>,
#[sea_orm(column_type = "Text", nullable)]
pub notification_webhook_url: Option<String>,
pub notification_channel_type: Option<String>,

View File

@@ -0,0 +1,43 @@
//! `SeaORM` Entity, manually maintained
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "worker_jobs")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub parent_job_id: Option<i32>,
pub job_kind: String,
pub worker_name: String,
pub display_name: Option<String>,
pub status: String,
pub queue_name: Option<String>,
pub requested_by: Option<String>,
pub requested_source: Option<String>,
pub trigger_mode: Option<String>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub payload: Option<Json>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub result: Option<Json>,
#[sea_orm(column_type = "Text", nullable)]
pub error_text: Option<String>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub tags: Option<Json>,
pub related_entity_type: Option<String>,
pub related_entity_id: Option<String>,
pub attempts_count: i32,
pub max_attempts: i32,
pub cancel_requested: bool,
pub queued_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use chrono::{offset::Local, Duration};
use chrono::{Duration, offset::Local};
use loco_rs::{auth::jwt, hash, prelude::*};
use serde::{Deserialize, Serialize};
use serde_json::Map;

View File

@@ -3,12 +3,9 @@ use std::{
sync::{Mutex, OnceLock},
};
use axum::http::{header, HeaderMap, StatusCode};
use axum::http::{HeaderMap, StatusCode, header};
use chrono::{DateTime, Duration, Utc};
use loco_rs::{
controller::ErrorDetail,
prelude::*,
};
use loco_rs::{controller::ErrorDetail, prelude::*};
const DEFAULT_WINDOW_SECONDS: i64 = 5 * 60;
const DEFAULT_MAX_REQUESTS_PER_WINDOW: u32 = 45;

View File

@@ -1,33 +1,15 @@
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, Set};
use loco_rs::prelude::{AppContext, Result};
use crate::{
controllers::admin::AdminIdentity,
models::_entities::admin_audit_logs,
};
use crate::controllers::admin::AdminIdentity;
pub async fn log_event(
ctx: &AppContext,
actor: Option<&AdminIdentity>,
action: &str,
target_type: &str,
target_id: Option<String>,
target_label: Option<String>,
metadata: Option<serde_json::Value>,
_ctx: &AppContext,
_actor: Option<&AdminIdentity>,
_action: &str,
_target_type: &str,
_target_id: Option<String>,
_target_label: Option<String>,
_metadata: Option<serde_json::Value>,
) -> Result<()> {
admin_audit_logs::ActiveModel {
actor_username: Set(actor.map(|item| item.username.clone())),
actor_email: Set(actor.and_then(|item| item.email.clone())),
actor_source: Set(actor.map(|item| item.source.clone())),
action: Set(action.to_string()),
target_type: Set(target_type.to_string()),
target_id: Set(target_id),
target_label: Set(target_label),
metadata: Set(metadata),
..Default::default()
}
.insert(&ctx.db)
.await?;
Ok(())
}

View File

@@ -7,19 +7,21 @@ use loco_rs::prelude::*;
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
use sea_orm::{
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
PaginatorTrait, QueryOrder, Set, Statement,
PaginatorTrait, QueryOrder, Set, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use uuid::Uuid;
use crate::{
controllers::site_settings as site_settings_controller,
models::_entities::{ai_chunks, site_settings},
services::{content, storage},
services::{content, storage, worker_jobs},
};
const DEFAULT_AI_PROVIDER: &str = "openai";
@@ -36,9 +38,12 @@ const DEFAULT_TOP_K: usize = 4;
const DEFAULT_CHUNK_SIZE: usize = 1200;
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
const EMBEDDING_BATCH_SIZE: usize = 32;
pub(crate) const REINDEX_EMBEDDING_BATCH_SIZE: usize = 4;
const EMBEDDING_DIMENSION: usize = 384;
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
const LOCAL_EMBEDDING_IDLE_TIMEOUT_SECS: u64 = 300;
const LOCAL_EMBEDDING_REAPER_INTERVAL_SECS: u64 = 30;
const LOCAL_EMBEDDING_BASE_URL: &str =
"https://huggingface.co/Qdrant/all-MiniLM-L6-v2-onnx/resolve/main";
const LOCAL_EMBEDDING_FILES: [&str; 5] = [
@@ -49,7 +54,13 @@ const LOCAL_EMBEDDING_FILES: [&str; 5] = [
"tokenizer_config.json",
];
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<Option<LocalEmbeddingRuntime>>> = OnceLock::new();
static TEXT_EMBEDDING_REAPER_STARTED: OnceLock<()> = OnceLock::new();
struct LocalEmbeddingRuntime {
model: TextEmbedding,
last_used_at: Instant,
}
#[derive(Clone, Debug)]
struct AiImageRuntimeSettings {
@@ -202,6 +213,18 @@ pub struct AiIndexSummary {
pub last_indexed_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Serialize)]
struct AiReindexProgress {
phase: String,
message: String,
total_chunks: usize,
processed_chunks: usize,
total_batches: usize,
current_batch: usize,
batch_size: usize,
percent: usize,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
@@ -390,18 +413,78 @@ fn load_local_embedding_model() -> Result<TextEmbedding> {
.map_err(|error| Error::BadRequest(format!("本地 embedding 模型初始化失败: {error}")))
}
fn local_embedding_engine() -> Result<&'static Mutex<TextEmbedding>> {
if let Some(model) = TEXT_EMBEDDING_MODEL.get() {
return Ok(model);
fn local_embedding_state() -> &'static Mutex<Option<LocalEmbeddingRuntime>> {
TEXT_EMBEDDING_MODEL.get_or_init(|| Mutex::new(None))
}
fn ensure_local_embedding_reaper_started() {
TEXT_EMBEDDING_REAPER_STARTED.get_or_init(|| {
if let Err(error) = thread::Builder::new()
.name("local-embedding-reaper".to_string())
.spawn(|| {
let idle_timeout = Duration::from_secs(LOCAL_EMBEDDING_IDLE_TIMEOUT_SECS);
let check_interval = Duration::from_secs(LOCAL_EMBEDDING_REAPER_INTERVAL_SECS);
loop {
thread::sleep(check_interval);
let Some(state) = TEXT_EMBEDDING_MODEL.get() else {
continue;
};
let mut guard = match state.lock() {
Ok(guard) => guard,
Err(_) => {
tracing::warn!("failed to lock local embedding model for idle cleanup");
continue;
}
};
let should_unload = guard
.as_ref()
.is_some_and(|runtime| runtime.last_used_at.elapsed() >= idle_timeout);
if should_unload {
*guard = None;
tracing::info!(
"unloaded local embedding model after {} seconds of inactivity",
LOCAL_EMBEDDING_IDLE_TIMEOUT_SECS
);
}
}
})
{
tracing::warn!("failed to start local embedding reaper thread: {error}");
}
});
}
fn with_local_embedding_engine<T>(
operation: impl FnOnce(&mut TextEmbedding) -> Result<T>,
) -> Result<T> {
ensure_local_embedding_reaper_started();
let state = local_embedding_state();
let mut guard = state
.lock()
.map_err(|_| Error::BadRequest("本地 embedding 模型当前不可用,请稍后重试".to_string()))?;
if guard.is_none() {
tracing::info!("loading local embedding model into memory");
*guard = Some(LocalEmbeddingRuntime {
model: load_local_embedding_model()?,
last_used_at: Instant::now(),
});
}
let model = load_local_embedding_model()?;
let runtime = guard
.as_mut()
.ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string()))?;
runtime.last_used_at = Instant::now();
let _ = TEXT_EMBEDDING_MODEL.set(Mutex::new(model));
TEXT_EMBEDDING_MODEL
.get()
.ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string()))
let result = operation(&mut runtime.model);
runtime.last_used_at = Instant::now();
result
}
fn vector_literal(embedding: &[f64]) -> Result<String> {
@@ -771,25 +854,30 @@ pub fn default_image_model_for_provider(provider: &str) -> &'static str {
}
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
embed_texts_locally_with_batch_size(inputs, kind, EMBEDDING_BATCH_SIZE).await
}
async fn embed_texts_locally_with_batch_size(
inputs: Vec<String>,
kind: EmbeddingKind,
batch_size: usize,
) -> Result<Vec<Vec<f64>>> {
tokio::task::spawn_blocking(move || {
let model = local_embedding_engine()?;
let prepared = inputs
.iter()
.map(|item| prepare_embedding_text(kind, item))
.collect::<Vec<_>>();
let mut guard = model.lock().map_err(|_| {
Error::BadRequest("本地 embedding 模型当前不可用,请稍后重试".to_string())
})?;
with_local_embedding_engine(|model| {
let embeddings = model
.embed(prepared, Some(batch_size.max(1)))
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
let embeddings = guard
.embed(prepared, Some(EMBEDDING_BATCH_SIZE))
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
Ok(embeddings
.into_iter()
.map(|embedding| embedding.into_iter().map(f64::from).collect::<Vec<_>>())
.collect::<Vec<_>>())
Ok(embeddings
.into_iter()
.map(|embedding| embedding.into_iter().map(f64::from).collect::<Vec<_>>())
.collect::<Vec<_>>())
})
})
.await
.map_err(|error| Error::BadRequest(format!("本地 embedding 任务执行失败: {error}")))?
@@ -2461,6 +2549,73 @@ fn retrieval_only_answer(matches: &[ScoredChunk]) -> String {
)
}
fn build_reindex_progress(
phase: &str,
message: String,
total_chunks: usize,
processed_chunks: usize,
batch_size: usize,
) -> AiReindexProgress {
let normalized_batch_size = batch_size.max(1);
let total_batches = total_chunks.div_ceil(normalized_batch_size);
let current_batch = if processed_chunks == 0 {
0
} else {
processed_chunks
.div_ceil(normalized_batch_size)
.min(total_batches)
};
let percent = if total_chunks == 0 {
100
} else {
((processed_chunks * 100) / total_chunks).min(100)
};
AiReindexProgress {
phase: phase.to_string(),
message,
total_chunks,
processed_chunks,
total_batches,
current_batch,
batch_size: normalized_batch_size,
percent,
}
}
async fn update_reindex_job_progress(
ctx: &AppContext,
job_id: Option<i32>,
progress: &AiReindexProgress,
) -> Result<()> {
let Some(job_id) = job_id else {
return Ok(());
};
worker_jobs::update_job_result(
ctx,
job_id,
serde_json::json!({
"phase": progress.phase,
"message": progress.message,
"progress": progress,
}),
)
.await
}
async fn stop_reindex_if_cancel_requested(ctx: &AppContext, job_id: Option<i32>) -> Result<()> {
let Some(job_id) = job_id else {
return Ok(());
};
if worker_jobs::cancel_job_if_requested(ctx, job_id, "job cancelled during reindex").await? {
return Err(Error::BadRequest("job cancelled".to_string()));
}
Ok(())
}
async fn load_runtime_settings(
ctx: &AppContext,
require_enabled: bool,
@@ -2555,14 +2710,14 @@ async fn load_runtime_settings(
})
}
async fn update_indexed_at(
ctx: &AppContext,
async fn update_indexed_at<C: ConnectionTrait>(
db: &C,
settings: &site_settings::Model,
) -> Result<DateTime<Utc>> {
let now = Utc::now();
let mut model = settings.clone().into_active_model();
model.ai_last_indexed_at = Set(Some(now.into()));
let _ = model.update(&ctx.db).await?;
let _ = model.update(db).await?;
Ok(now)
}
@@ -2571,14 +2726,8 @@ async fn retrieve_matches(
settings: &AiRuntimeSettings,
question: &str,
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
if indexed_chunks == 0 {
let summary = rebuild_index(ctx).await?;
indexed_chunks = summary.indexed_chunks;
last_indexed_at = summary.last_indexed_at;
}
let indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
let last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
if indexed_chunks == 0 {
return Ok((Vec::new(), 0, last_indexed_at));
@@ -2640,66 +2789,100 @@ async fn retrieve_matches(
Ok((matches, indexed_chunks, last_indexed_at))
}
pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIndexSummary> {
let settings = load_runtime_settings(ctx, false).await?;
let posts = content::load_markdown_posts_from_store(ctx).await?;
let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
let embeddings = if chunk_drafts.is_empty() {
Vec::new()
} else {
embed_texts_locally(
chunk_drafts
let total_chunks = chunk_drafts.len();
let batch_size = REINDEX_EMBEDDING_BATCH_SIZE.max(1);
let preparing_progress = build_reindex_progress(
"preparing",
if total_chunks == 0 {
"没有可写入的内容,正在清理旧索引。".to_string()
} else {
format!("已收集 {total_chunks} 个分块,准备重建向量索引。")
},
total_chunks,
0,
batch_size,
);
update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let txn = ctx.db.begin().await?;
txn.execute(Statement::from_string(
DbBackend::Postgres,
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
))
.await?;
let mut processed_chunks = 0usize;
for chunk_batch in chunk_drafts.chunks(batch_size) {
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let embeddings = embed_texts_locally_with_batch_size(
chunk_batch
.iter()
.map(|chunk| chunk.content.clone())
.collect::<Vec<_>>(),
EmbeddingKind::Passage,
batch_size,
)
.await?
};
ctx.db
.execute(Statement::from_string(
DbBackend::Postgres,
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
))
.await?;
for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) {
let embedding_literal = vector_literal(&embedding)?;
let statement = Statement::from_sql_and_values(
DbBackend::Postgres,
r#"
INSERT INTO ai_chunks (
source_slug,
source_title,
source_path,
source_type,
chunk_index,
content,
content_preview,
embedding,
word_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::vector, $9
)
"#,
vec![
draft.source_slug.clone().into(),
draft.source_title.clone().into(),
draft.source_path.clone().into(),
draft.source_type.clone().into(),
draft.chunk_index.into(),
draft.content.clone().into(),
draft.content_preview.clone().into(),
embedding_literal.into(),
draft.word_count.into(),
],
for (draft, embedding) in chunk_batch.iter().zip(embeddings.into_iter()) {
let embedding_literal = vector_literal(&embedding)?;
let statement = Statement::from_sql_and_values(
DbBackend::Postgres,
r#"
INSERT INTO ai_chunks (
source_slug,
source_title,
source_path,
source_type,
chunk_index,
content,
content_preview,
embedding,
word_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::vector, $9
)
"#,
vec![
draft.source_slug.clone().into(),
draft.source_title.clone().into(),
draft.source_path.clone().into(),
draft.source_type.clone().into(),
draft.chunk_index.into(),
draft.content.clone().into(),
draft.content_preview.clone().into(),
embedding_literal.into(),
draft.word_count.into(),
],
);
txn.execute(statement).await?;
}
processed_chunks += chunk_batch.len();
let embedding_progress = build_reindex_progress(
"embedding",
format!(
"正在写入第 {}/{} 批向量。",
processed_chunks.div_ceil(batch_size),
total_chunks.div_ceil(batch_size)
),
total_chunks,
processed_chunks,
batch_size,
);
ctx.db.execute(statement).await?;
update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
}
let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?;
stop_reindex_if_cancel_requested(ctx, job_id).await?;
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
txn.commit().await?;
Ok(AiIndexSummary {
indexed_chunks: chunk_drafts.len(),

View File

@@ -140,6 +140,8 @@ pub struct AdminAnalyticsResponse {
pub recent_events: Vec<AnalyticsRecentEvent>,
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
pub top_referrers: Vec<AnalyticsReferrerBucket>,
pub ai_referrers_last_7d: Vec<AnalyticsReferrerBucket>,
pub ai_discovery_page_views_last_7d: u64,
pub popular_posts: Vec<AnalyticsPopularPost>,
pub daily_activity: Vec<AnalyticsDailyBucket>,
}
@@ -197,16 +199,109 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M").to_string()
}
fn normalize_referrer_source(value: Option<String>) -> String {
fn metadata_string(metadata: Option<&serde_json::Value>, key: &str) -> Option<String> {
metadata
.and_then(|value| value.get(key))
.and_then(|value| value.as_str())
.map(ToString::to_string)
.and_then(|value| trim_to_option(Some(value)))
}
fn parse_path_query_value(path: Option<&str>, key: &str) -> Option<String> {
let path = path.and_then(|value| trim_to_option(Some(value.to_string())))?;
let synthetic_url = if path.starts_with("http://") || path.starts_with("https://") {
path
} else if path.starts_with('/') {
format!("https://local.test{path}")
} else {
format!("https://local.test/{path}")
};
reqwest::Url::parse(&synthetic_url)
.ok()
.and_then(|url| {
url.query_pairs()
.find(|(item_key, _)| item_key == key)
.map(|(_, value)| value.to_string())
})
.and_then(|value| trim_to_option(Some(value)))
}
fn normalize_tracking_source_token(value: Option<String>) -> String {
let Some(value) = trim_to_option(value) else {
return "direct".to_string();
};
reqwest::Url::parse(&value)
let normalized = reqwest::Url::parse(&value)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
.filter(|item| !item.trim().is_empty())
.unwrap_or(value)
.trim()
.to_ascii_lowercase();
match normalized.as_str() {
"direct" => "direct".to_string(),
value if value.contains("chatgpt") || value.contains("openai") => {
"chatgpt-search".to_string()
}
value if value.contains("perplexity") => "perplexity".to_string(),
value if value.contains("copilot") || value.contains("bing") => "copilot-bing".to_string(),
value if value.contains("gemini") => "gemini".to_string(),
value if value.contains("google") => "google".to_string(),
value if value.contains("claude") => "claude".to_string(),
value if value.contains("duckduckgo") => "duckduckgo".to_string(),
value if value.contains("kagi") => "kagi".to_string(),
_ => normalized,
}
}
fn normalize_tracking_source(
path: Option<&str>,
referrer: Option<String>,
metadata: Option<&serde_json::Value>,
) -> String {
let preferred = metadata_string(metadata, "landingSource")
.or_else(|| metadata_string(metadata, "landing_source"))
.or_else(|| metadata_string(metadata, "utmSource"))
.or_else(|| metadata_string(metadata, "utm_source"))
.or_else(|| parse_path_query_value(path, "utm_source"))
.or_else(|| metadata_string(metadata, "referrerHost"))
.or_else(|| referrer);
normalize_tracking_source_token(preferred)
}
fn is_ai_discovery_source(value: &str) -> bool {
matches!(
value,
"chatgpt-search" | "perplexity" | "copilot-bing" | "gemini" | "claude"
)
}
fn sorted_referrer_buckets(
breakdown: &HashMap<String, u64>,
predicate: impl Fn(&str) -> bool,
limit: usize,
) -> Vec<AnalyticsReferrerBucket> {
let mut items = breakdown
.iter()
.filter_map(|(referrer, count)| {
predicate(referrer).then(|| AnalyticsReferrerBucket {
referrer: referrer.clone(),
count: *count,
})
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.referrer.cmp(&right.referrer))
});
items.truncate(limit);
items
}
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
@@ -550,7 +645,11 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
page_views_last_24h += 1;
}
let referrer = normalize_referrer_source(event.referrer.clone());
let referrer = normalize_tracking_source(
Some(&event.path),
event.referrer.clone(),
event.metadata.as_ref(),
);
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
}
@@ -637,22 +736,29 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
});
providers_last_7d.truncate(6);
let mut top_referrers = referrer_breakdown
.into_iter()
.map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count })
.collect::<Vec<_>>();
top_referrers.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.referrer.cmp(&right.referrer))
});
top_referrers.truncate(8);
let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8);
let ai_referrers_last_7d =
sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6);
let ai_discovery_page_views_last_7d = referrer_breakdown
.iter()
.filter(|(referrer, _)| is_ai_discovery_source(referrer))
.map(|(_, count)| *count)
.sum::<u64>();
let mut popular_posts = post_breakdown
.into_iter()
.map(
|(slug, (page_views, read_completes, total_progress, progress_count, total_duration, duration_count))| {
|(
slug,
(
page_views,
read_completes,
total_progress,
progress_count,
total_duration,
duration_count,
),
)| {
AnalyticsPopularPost {
title: post_titles
.get(&slug)
@@ -748,6 +854,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
recent_events,
providers_last_7d,
top_referrers,
ai_referrers_last_7d,
ai_discovery_page_views_last_7d,
popular_posts,
daily_activity,
})
@@ -921,7 +1029,8 @@ pub async fn build_public_content_highlights(
} else {
0.0
},
avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64),
avg_duration_ms: (duration_count > 0)
.then(|| total_duration / duration_count as f64),
},
)
.collect::<Vec<_>>();
@@ -988,8 +1097,22 @@ pub async fn build_public_content_windows(
.await?;
Ok(vec![
summarize_public_content_window(&events, &post_titles, now - Duration::hours(24), "24h", "24h", 1),
summarize_public_content_window(&events, &post_titles, now - Duration::days(7), "7d", "7d", 7),
summarize_public_content_window(
&events,
&post_titles,
now - Duration::hours(24),
"24h",
"24h",
1,
),
summarize_public_content_window(
&events,
&post_titles,
now - Duration::days(7),
"7d",
"7d",
7,
),
summarize_public_content_window(&events, &post_titles, since_30d, "30d", "30d", 30),
])
}
@@ -1136,7 +1259,8 @@ fn summarize_public_content_window(
} else {
0.0
},
avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64),
avg_duration_ms: (duration_count > 0)
.then(|| total_duration / duration_count as f64),
},
)
.collect::<Vec<_>>();

View File

@@ -30,16 +30,23 @@ struct MarkdownFrontmatter {
deserialize_with = "deserialize_optional_string_list"
)]
categories: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_optional_string_list")]
#[serde(
default,
alias = "tag",
deserialize_with = "deserialize_optional_string_list"
)]
tags: Option<Vec<String>>,
post_type: Option<String>,
image: Option<String>,
images: Option<Vec<String>>,
pinned: Option<bool>,
#[serde(alias = "Hidden")]
hidden: Option<bool>,
published: Option<bool>,
draft: Option<bool>,
status: Option<String>,
visibility: Option<String>,
#[serde(alias = "date")]
publish_at: Option<String>,
unpublish_at: Option<String>,
canonical_url: Option<String>,
@@ -233,6 +240,18 @@ fn resolve_post_status(frontmatter: &MarkdownFrontmatter) -> String {
}
}
fn resolve_post_visibility(frontmatter: &MarkdownFrontmatter) -> String {
if let Some(visibility) = trim_to_option(frontmatter.visibility.clone()) {
return normalize_post_visibility(Some(&visibility));
}
if frontmatter.hidden.unwrap_or(false) {
POST_VISIBILITY_UNLISTED.to_string()
} else {
POST_VISIBILITY_PUBLIC.to_string()
}
}
pub fn effective_post_state(
status: &str,
publish_at: Option<DateTime<FixedOffset>>,
@@ -500,7 +519,7 @@ pub fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Res
images: normalize_string_list(frontmatter.images.clone()),
pinned: frontmatter.pinned.unwrap_or(false),
status: resolve_post_status(&frontmatter),
visibility: normalize_post_visibility(frontmatter.visibility.as_deref()),
visibility: resolve_post_visibility(&frontmatter),
publish_at: format_frontmatter_datetime(parse_frontmatter_datetime(
frontmatter.publish_at.clone(),
)),
@@ -1152,3 +1171,39 @@ pub async fn import_markdown_documents(
Ok(imported)
}
#[cfg(test)]
mod tests {
use super::{POST_VISIBILITY_UNLISTED, parse_markdown_source};
#[test]
fn parse_markdown_source_supports_hugo_aliases() {
let markdown = r#"---
title: "Linux Shell"
date: 2022-05-21T10:02:09+08:00
draft: false
Hidden: true
slug: linux-shell
categories:
- Linux
tag:
- Linux
- Shell
---
# Linux Shell
"#;
let post = parse_markdown_source("linux-shell", markdown, "content/posts/linux-shell.md")
.expect("markdown should parse");
assert_eq!(post.slug, "linux-shell");
assert_eq!(post.category.as_deref(), Some("Linux"));
assert_eq!(post.tags, vec!["Linux", "Shell"]);
assert_eq!(post.visibility, POST_VISIBILITY_UNLISTED);
assert_eq!(
post.publish_at.as_deref(),
Some("2022-05-21T02:02:09+00:00")
);
}
}

View File

@@ -1,5 +1,5 @@
pub mod admin_audit;
pub mod abuse_guard;
pub mod admin_audit;
pub mod ai;
pub mod analytics;
pub mod backups;
@@ -12,3 +12,4 @@ pub mod storage;
pub mod subscriptions;
pub mod turnstile;
pub mod web_push;
pub mod worker_jobs;

View File

@@ -1,9 +1,9 @@
use loco_rs::prelude::*;
use crate::{
controllers::site_settings,
models::_entities::{comments, friend_links, site_settings as site_settings_model},
services::subscriptions,
};
use loco_rs::prelude::*;
fn notification_channel_type(settings: &site_settings_model::Model) -> &'static str {
match settings
@@ -71,10 +71,16 @@ pub async fn notify_new_comment(ctx: &AppContext, item: &comments::Model) {
});
let text = format!(
"收到一条新的评论。\n\n文章:{}\n作者:{}\n范围:{}\n状态:{}\n摘要:{}",
item.post_slug.clone().unwrap_or_else(|| "未知文章".to_string()),
item.post_slug
.clone()
.unwrap_or_else(|| "未知文章".to_string()),
item.author.clone().unwrap_or_else(|| "匿名".to_string()),
item.scope,
if item.approved.unwrap_or(false) { "已通过" } else { "待审核" },
if item.approved.unwrap_or(false) {
"已通过"
} else {
"待审核"
},
excerpt(item.content.as_deref(), 200).unwrap_or_else(|| "".to_string()),
);
@@ -135,9 +141,13 @@ pub async fn notify_new_friend_link(ctx: &AppContext, item: &friend_links::Model
});
let text = format!(
"收到新的友链申请。\n\n站点:{}\n链接:{}\n分类:{}\n状态:{}\n描述:{}",
item.site_name.clone().unwrap_or_else(|| "未命名站点".to_string()),
item.site_name
.clone()
.unwrap_or_else(|| "未命名站点".to_string()),
item.site_url,
item.category.clone().unwrap_or_else(|| "未分类".to_string()),
item.category
.clone()
.unwrap_or_else(|| "未分类".to_string()),
item.status.clone().unwrap_or_else(|| "pending".to_string()),
item.description.clone().unwrap_or_else(|| "".to_string()),
);

View File

@@ -1,5 +1,5 @@
use aws_config::BehaviorVersion;
use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client};
use aws_sdk_s3::{Client, config::Credentials, primitives::ByteStream};
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryOrder};
use std::path::{Path, PathBuf};

View File

@@ -1,8 +1,5 @@
use chrono::{Duration, Utc};
use loco_rs::{
bgworker::BackgroundWorker,
prelude::*,
};
use loco_rs::prelude::*;
use reqwest::Client;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder,
@@ -15,10 +12,7 @@ use uuid::Uuid;
use crate::{
mailers::subscription::SubscriptionMailer,
models::_entities::{notification_deliveries, posts, subscriptions},
services::{content, web_push as web_push_service},
workers::notification_delivery::{
NotificationDeliveryWorker, NotificationDeliveryWorkerArgs,
},
services::{content, web_push as web_push_service, worker_jobs},
};
pub const CHANNEL_EMAIL: &str = "email";
@@ -46,7 +40,12 @@ pub const DELIVERY_STATUS_RETRY_PENDING: &str = "retry_pending";
pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted";
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped";
const MAX_DELIVERY_ATTEMPTS: i32 = 5;
const WEB_PUSH_TITLE_MAX_CHARS: usize = 72;
const WEB_PUSH_BODY_MAX_CHARS: usize = 160;
const WEB_PUSH_MAX_PAYLOAD_BYTES: usize = 2800;
const WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD: i32 = 2;
const WEB_PUSH_AUTO_PAUSE_NOTE: &str =
"浏览器推送订阅连续投递失败,系统已自动暂停。请在浏览器里重新开启提醒。";
#[derive(Clone, Debug, Serialize)]
pub struct DigestDispatchSummary {
@@ -249,15 +248,113 @@ fn normalize_browser_push_subscription(raw: Value) -> Result<Value> {
serde_json::to_value(subscription).map_err(Into::into)
}
fn merge_browser_push_metadata(existing: Option<&Value>, incoming: Option<Value>, subscription: Value) -> Value {
fn merge_browser_push_metadata(
existing: Option<&Value>,
incoming: Option<Value>,
subscription: Value,
) -> Value {
let mut object = merge_metadata(existing, incoming)
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
object.insert("kind".to_string(), Value::String("browser-push".to_string()));
object.insert(
"kind".to_string(),
Value::String("browser-push".to_string()),
);
object.insert("subscription".to_string(), subscription);
Value::Object(object)
}
fn merge_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
let note = note.trim();
let mut lines = existing
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>();
if !note.is_empty() && !lines.iter().any(|line| line == note) {
lines.push(note.to_string());
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn remove_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
let note = note.trim();
let lines = existing
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && *line != note)
.map(ToString::to_string)
.collect::<Vec<_>>();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn web_push_error_looks_terminal(error_text: &str) -> bool {
let normalized = error_text.trim().to_ascii_lowercase();
normalized.contains("endpoint_host=fcm.googleapis.com")
&& normalized.contains("unspecified error")
|| normalized.contains("410")
|| normalized.contains("404")
|| normalized.contains("gone")
|| normalized.contains("not found")
|| normalized.contains("expired")
|| normalized.contains("unsubscribed")
|| normalized.contains("invalid subscription")
|| normalized.contains("push subscription")
}
fn should_auto_pause_failed_web_push_subscription(
failure_count_after_error: i32,
error_text: &str,
) -> bool {
failure_count_after_error >= WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD
|| web_push_error_looks_terminal(error_text)
}
async fn maybe_pause_failed_web_push_subscription(
ctx: &AppContext,
subscription: Option<&subscriptions::Model>,
error_text: &str,
) -> Result<()> {
let Some(subscription) = subscription else {
return Ok(());
};
if subscription.channel_type != CHANNEL_WEB_PUSH
|| normalize_status(&subscription.status) != STATUS_ACTIVE
{
return Ok(());
}
let failure_count_after_error = subscription.failure_count.unwrap_or(0) + 1;
if !should_auto_pause_failed_web_push_subscription(failure_count_after_error, error_text) {
return Ok(());
}
let mut active = subscription.clone().into_active_model();
active.status = Set(STATUS_PAUSED.to_string());
active.notes = Set(merge_subscription_note(
subscription.notes.as_deref(),
WEB_PUSH_AUTO_PAUSE_NOTE,
));
let _ = active.update(&ctx.db).await?;
Ok(())
}
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
value
.and_then(Value::as_object)
@@ -286,7 +383,8 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
if let Some(items) = payload.get(key).and_then(Value::as_array) {
values.extend(
items.iter()
items
.iter()
.filter_map(Value::as_str)
.map(normalize_string)
.filter(|item| !item.is_empty()),
@@ -304,7 +402,8 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
if let Some(items) = post.get(key).and_then(Value::as_array) {
values.extend(
items.iter()
items
.iter()
.filter_map(Value::as_str)
.map(normalize_string)
.filter(|item| !item.is_empty()),
@@ -318,16 +417,6 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
values
}
fn delivery_retry_delay(attempts: i32) -> Duration {
match attempts {
0 | 1 => Duration::minutes(1),
2 => Duration::minutes(5),
3 => Duration::minutes(15),
4 => Duration::minutes(60),
_ => Duration::hours(6),
}
}
fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
match period.trim().to_ascii_lowercase().as_str() {
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY),
@@ -416,19 +505,31 @@ pub fn to_public_subscription_view(item: &subscriptions::Model) -> PublicSubscri
}
}
fn subscription_links(item: &subscriptions::Model, site_context: &SiteContext) -> (Option<String>, Option<String>, Option<String>) {
let manage_url = item
.manage_token
.as_deref()
.and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/manage", token));
let unsubscribe_url = item
.manage_token
.as_deref()
.and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/unsubscribe", token));
let confirm_url = item
.confirm_token
.as_deref()
.and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/confirm", token));
fn subscription_links(
item: &subscriptions::Model,
site_context: &SiteContext,
) -> (Option<String>, Option<String>, Option<String>) {
let manage_url = item.manage_token.as_deref().and_then(|token| {
build_token_link(
site_context.site_url.as_deref(),
"/subscriptions/manage",
token,
)
});
let unsubscribe_url = item.manage_token.as_deref().and_then(|token| {
build_token_link(
site_context.site_url.as_deref(),
"/subscriptions/unsubscribe",
token,
)
});
let confirm_url = item.confirm_token.as_deref().and_then(|token| {
build_token_link(
site_context.site_url.as_deref(),
"/subscriptions/confirm",
token,
)
});
(manage_url, unsubscribe_url, confirm_url)
}
@@ -455,7 +556,11 @@ async fn send_confirmation_email(ctx: &AppContext, item: &subscriptions::Model)
.await
}
fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payload: &Value) -> bool {
fn subscription_allows_event(
item: &subscriptions::Model,
event_type: &str,
payload: &Value,
) -> bool {
if normalize_status(&item.status) != STATUS_ACTIVE {
return false;
}
@@ -493,7 +598,9 @@ fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payl
if !tags.is_empty() {
let payload_tags = payload_match_strings(payload, "tags");
if payload_tags.is_empty()
|| !tags.iter().any(|tag| payload_tags.iter().any(|item| item == tag))
|| !tags
.iter()
.any(|tag| payload_tags.iter().any(|item| item == tag))
{
return false;
}
@@ -507,10 +614,15 @@ pub async fn list_subscriptions(
channel_type: Option<&str>,
status: Option<&str>,
) -> Result<Vec<subscriptions::Model>> {
let mut query = subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc);
let mut query =
subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc);
if let Some(channel_type) = channel_type.map(str::trim).filter(|value| !value.is_empty()) {
query = query.filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type)));
if let Some(channel_type) = channel_type
.map(str::trim)
.filter(|value| !value.is_empty())
{
query = query
.filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type)));
}
if let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) {
@@ -654,6 +766,12 @@ pub async fn create_public_web_push_subscription(
active.status = Set(STATUS_ACTIVE.to_string());
active.confirm_token = Set(None);
active.verified_at = Set(Some(Utc::now().to_rfc3339()));
active.failure_count = Set(Some(0));
active.last_delivery_status = Set(None);
active.notes = Set(remove_subscription_note(
existing.notes.as_deref(),
WEB_PUSH_AUTO_PAUSE_NOTE,
));
active.metadata = Set(Some(merge_browser_push_metadata(
existing.metadata.as_ref(),
metadata,
@@ -777,7 +895,9 @@ pub async fn update_subscription_preferences(
if let Some(status) = status {
let normalized = normalize_status(&status);
if normalized == STATUS_PENDING {
return Err(Error::BadRequest("偏好页不支持将状态改回 pending".to_string()));
return Err(Error::BadRequest(
"偏好页不支持将状态改回 pending".to_string(),
));
}
active.status = Set(normalized);
}
@@ -789,7 +909,10 @@ pub async fn update_subscription_preferences(
active.update(&ctx.db).await.map_err(Into::into)
}
pub async fn unsubscribe_subscription(ctx: &AppContext, token: &str) -> Result<subscriptions::Model> {
pub async fn unsubscribe_subscription(
ctx: &AppContext,
token: &str,
) -> Result<subscriptions::Model> {
let item = get_subscription_by_manage_token(ctx, token).await?;
let mut active = item.into_active_model();
active.status = Set(STATUS_UNSUBSCRIBED.to_string());
@@ -827,24 +950,22 @@ async fn update_subscription_delivery_state(
let mut active = subscription.into_active_model();
active.last_notified_at = Set(Some(Utc::now().to_rfc3339()));
active.last_delivery_status = Set(Some(status.to_string()));
active.failure_count = Set(Some(if success {
0
} else {
current_failures + 1
}));
active.failure_count = Set(Some(if success { 0 } else { current_failures + 1 }));
let _ = active.update(&ctx.db).await?;
Ok(())
}
async fn enqueue_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> {
match NotificationDeliveryWorker::perform_later(ctx, NotificationDeliveryWorkerArgs { delivery_id }).await {
Ok(_) => Ok(()),
Err(Error::QueueProviderMissing) => process_delivery(ctx, delivery_id).await,
Err(error) => {
tracing::warn!("failed to enqueue delivery #{delivery_id}, falling back to sync processing: {error}");
process_delivery(ctx, delivery_id).await
}
}
let _ = worker_jobs::queue_notification_delivery_job(
ctx,
delivery_id,
None,
Some("system".to_string()),
None,
Some("system".to_string()),
)
.await?;
Ok(())
}
pub async fn queue_direct_notification(
@@ -949,10 +1070,16 @@ pub async fn queue_event_for_active_subscriptions(
) -> Result<QueueDispatchSummary> {
let subscriptions = active_subscriptions(ctx).await?;
if subscriptions.is_empty() {
return Ok(QueueDispatchSummary { queued: 0, skipped: 0 });
return Ok(QueueDispatchSummary {
queued: 0,
skipped: 0,
});
}
let site_context = SiteContext { site_name, site_url };
let site_context = SiteContext {
site_name,
site_url,
};
let mut queued = 0usize;
let mut skipped = 0usize;
@@ -1031,26 +1158,47 @@ fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
}
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
let body = truncate_chars(&collapse_whitespace(&message.text), 220);
let title = truncate_chars(
&collapse_whitespace(&message.subject),
WEB_PUSH_TITLE_MAX_CHARS,
);
let body = truncate_chars(&collapse_whitespace(&message.text), WEB_PUSH_BODY_MAX_CHARS);
let url = web_push_target_url(message);
let event_type = message
.payload
.get("event_type")
.and_then(Value::as_str)
.unwrap_or("subscription");
serde_json::json!({
"title": message.subject,
"title": title,
"body": body,
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
"url": web_push_target_url(message),
"tag": message
.payload
.get("event_type")
.and_then(Value::as_str)
.unwrap_or("subscription"),
"url": url.clone(),
"tag": event_type,
"data": {
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null),
"payload": message.payload,
"url": url,
"event_type": event_type,
}
})
}
fn encode_web_push_payload(message: &QueuedDeliveryPayload) -> Result<Vec<u8>> {
let payload = build_web_push_payload(message);
let encoded = serde_json::to_vec(&payload)?;
if encoded.len() > WEB_PUSH_MAX_PAYLOAD_BYTES {
return Err(Error::BadRequest(format!(
"web push payload too large: {} bytes exceeds safe limit {} bytes",
encoded.len(),
WEB_PUSH_MAX_PAYLOAD_BYTES
)));
}
Ok(encoded)
}
async fn deliver_via_channel(
ctx: &AppContext,
channel_type: &str,
@@ -1062,42 +1210,36 @@ async fn deliver_via_channel(
CHANNEL_EMAIL => Err(Error::BadRequest(
"email channel must be delivered via subscription context".to_string(),
)),
CHANNEL_DISCORD => {
Client::new()
.post(target)
.json(&serde_json::json!({ "content": message.text }))
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string()))
}
CHANNEL_TELEGRAM => {
Client::new()
.post(target)
.json(&serde_json::json!({ "text": message.text }))
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string()))
}
CHANNEL_NTFY => {
Client::new()
.post(resolve_ntfy_target(target))
.header("Title", &message.subject)
.header("Content-Type", "text/plain; charset=utf-8")
.body(message.text.clone())
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string()))
}
CHANNEL_DISCORD => Client::new()
.post(target)
.json(&serde_json::json!({ "content": message.text }))
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string())),
CHANNEL_TELEGRAM => Client::new()
.post(target)
.json(&serde_json::json!({ "text": message.text }))
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string())),
CHANNEL_NTFY => Client::new()
.post(resolve_ntfy_target(target))
.header("Title", &message.subject)
.header("Content-Type", "text/plain; charset=utf-8")
.body(message.text.clone())
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string())),
CHANNEL_WEB_PUSH => {
let settings = crate::controllers::site_settings::load_current(ctx).await?;
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
let payload = serde_json::to_vec(&build_web_push_payload(message))?;
let payload = encode_web_push_payload(message)?;
web_push_service::send_payload(
&settings,
&subscription_info,
@@ -1145,7 +1287,10 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
return Ok(());
};
if matches!(delivery.status.as_str(), DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED) {
if matches!(
delivery.status.as_str(),
DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED
) {
return Ok(());
}
@@ -1153,15 +1298,19 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.payload
.clone()
.ok_or_else(|| Error::BadRequest("delivery payload 为空".to_string()))
.and_then(|value| serde_json::from_value::<QueuedDeliveryPayload>(value).map_err(Into::into))?;
.and_then(|value| {
serde_json::from_value::<QueuedDeliveryPayload>(value).map_err(Into::into)
})?;
let attempts = delivery.attempts_count + 1;
let now = Utc::now().to_rfc3339();
let subscription = match delivery.subscription_id {
Some(subscription_id) => subscriptions::Entity::find_by_id(subscription_id)
.one(&ctx.db)
.await?,
Some(subscription_id) => {
subscriptions::Entity::find_by_id(subscription_id)
.one(&ctx.db)
.await?
}
None => None,
};
@@ -1175,7 +1324,13 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, Some(subscription.id), DELIVERY_STATUS_SKIPPED, false).await?;
update_subscription_delivery_state(
ctx,
Some(subscription.id),
DELIVERY_STATUS_SKIPPED,
false,
)
.await?;
return Ok(());
}
}
@@ -1206,7 +1361,14 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.await
}
} else {
deliver_via_channel(ctx, &delivery.channel_type, &delivery.target, &message, None).await
deliver_via_channel(
ctx,
&delivery.channel_type,
&delivery.target,
&message,
None,
)
.await
};
let subscription_id = delivery.subscription_id;
let delivery_channel_type = delivery.channel_type.clone();
@@ -1222,27 +1384,29 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true).await?;
update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true)
.await?;
}
Err(error) => {
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS)
.then(|| (Utc::now() + delivery_retry_delay(attempts)).to_rfc3339());
let status = if next_retry_at.is_some() {
DELIVERY_STATUS_RETRY_PENDING
} else {
DELIVERY_STATUS_EXHAUSTED
};
let error_text = error.to_string();
let mut active = delivery.into_active_model();
active.status = Set(status.to_string());
active.status = Set(DELIVERY_STATUS_EXHAUSTED.to_string());
active.provider = Set(Some(provider_name(&delivery_channel_type).to_string()));
active.response_text = Set(Some(error.to_string()));
active.response_text = Set(Some(error_text.clone()));
active.attempts_count = Set(attempts);
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339()));
active.next_retry_at = Set(next_retry_at);
active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, subscription_id, status, false).await?;
update_subscription_delivery_state(
ctx,
subscription_id,
DELIVERY_STATUS_EXHAUSTED,
false,
)
.await?;
maybe_pause_failed_web_push_subscription(ctx, subscription.as_ref(), &error_text)
.await?;
Err(error)?;
}
}
@@ -1302,7 +1466,10 @@ pub async fn send_test_notification(
.await
}
pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPost) -> Result<QueueDispatchSummary> {
pub async fn notify_post_published(
ctx: &AppContext,
post: &content::MarkdownPost,
) -> Result<QueueDispatchSummary> {
let site_context = load_site_context(ctx).await;
let public_url = post_public_url(site_context.site_url.as_deref(), &post.slug);
let subject = format!("新文章发布:{}", post.title);
@@ -1319,13 +1486,17 @@ pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPos
let text = format!(
"{}》已发布。\n\n分类:{}\n标签:{}\n链接:{}\n\n{}",
post.title,
post.category.clone().unwrap_or_else(|| "未分类".to_string()),
post.category
.clone()
.unwrap_or_else(|| "未分类".to_string()),
if post.tags.is_empty() {
"".to_string()
} else {
post.tags.join(", ")
},
public_url.clone().unwrap_or_else(|| format!("/articles/{}", post.slug)),
public_url
.clone()
.unwrap_or_else(|| format!("/articles/{}", post.slug)),
post.description.clone().unwrap_or_default(),
);
@@ -1359,7 +1530,8 @@ pub async fn send_digest(ctx: &AppContext, period: &str) -> Result<DigestDispatc
let lines = if posts.is_empty() {
vec![format!("最近 {} 天还没有新的公开文章。", days)]
} else {
posts.iter()
posts
.iter()
.map(|post| {
let url = post_public_url(site_context.site_url.as_deref(), &post.slug)
.unwrap_or_else(|| format!("/articles/{}", post.slug));
@@ -1373,7 +1545,14 @@ pub async fn send_digest(ctx: &AppContext, period: &str) -> Result<DigestDispatc
.collect::<Vec<_>>()
};
let subject = format!("{} 内容摘要", if normalized_period == "monthly" { "月报" } else { "周报" });
let subject = format!(
"{} 内容摘要",
if normalized_period == "monthly" {
"月报"
} else {
"周报"
}
);
let body = format!("统计周期:最近 {}\n\n{}", days, lines.join("\n\n"));
let payload = serde_json::json!({
"event_type": event_type,

View File

@@ -91,8 +91,7 @@ fn normalize_ip(value: Option<&str>) -> Option<String> {
}
fn verify_url() -> String {
env_value(ENV_TURNSTILE_VERIFY_URL)
.unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string())
env_value(ENV_TURNSTILE_VERIFY_URL).unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string())
}
fn client() -> &'static Client {
@@ -173,11 +172,10 @@ pub async fn verify_token(
token: Option<&str>,
client_ip: Option<&str>,
) -> Result<()> {
let secret = secret_key(settings).ok_or_else(|| {
Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string())
})?;
let response_token = trim_to_option(token)
.ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?;
let secret = secret_key(settings)
.ok_or_else(|| Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string()))?;
let response_token =
trim_to_option(token).ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?;
let mut form_data = vec![
("secret".to_string(), secret),

View File

@@ -1,4 +1,5 @@
use loco_rs::prelude::*;
use reqwest::Url;
use serde_json::Value;
use web_push::{
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
@@ -46,17 +47,30 @@ pub fn vapid_subject(settings: &site_settings::Model) -> Option<String> {
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
}
fn normalize_vapid_subject(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim();
if trimmed.starts_with("mailto:") || trimmed.starts_with("https://") {
Some(trimmed.to_string())
} else {
None
}
})
}
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
vapid_subject(settings)
.or_else(|| {
site_url
.map(str::trim)
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
.map(ToString::to_string)
})
normalize_vapid_subject(vapid_subject(settings))
.or_else(|| normalize_vapid_subject(site_url.map(ToString::to_string)))
.unwrap_or_else(|| "mailto:noreply@example.com".to_string())
}
fn subscription_endpoint_host(subscription_info: &SubscriptionInfo) -> String {
Url::parse(&subscription_info.endpoint)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
.unwrap_or_else(|| "unknown".to_string())
}
pub fn public_key_configured(settings: &site_settings::Model) -> bool {
public_key(settings).is_some()
}
@@ -66,9 +80,7 @@ pub fn private_key_configured(settings: &site_settings::Model) -> bool {
}
pub fn is_enabled(settings: &site_settings::Model) -> bool {
settings.web_push_enabled.unwrap_or(false)
&& public_key_configured(settings)
&& private_key_configured(settings)
public_key_configured(settings) && private_key_configured(settings)
}
pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result<SubscriptionInfo> {
@@ -92,10 +104,12 @@ pub async fn send_payload(
) -> Result<()> {
let private_key = private_key(settings)
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
let subject = effective_vapid_subject(settings, site_url);
let endpoint_host = subscription_endpoint_host(subscription_info);
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url));
signature_builder.add_claim("sub", subject.clone());
let signature = signature_builder
.build()
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
@@ -113,10 +127,11 @@ pub async fn send_payload(
.build()
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
client
.send(message)
.await
.map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?;
client.send(message).await.map_err(|error| {
Error::BadRequest(format!(
"web push send failed: {error}; vapid subject={subject}; endpoint_host={endpoint_host}"
))
})?;
Ok(())
}

View File

@@ -0,0 +1,965 @@
use chrono::Utc;
use loco_rs::{bgworker::BackgroundWorker, prelude::*};
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, Order, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
models::_entities::{notification_deliveries, worker_jobs},
services::subscriptions,
workers::{
ai_reindex::{AiReindexWorker, AiReindexWorkerArgs},
downloader::{DownloadWorker, DownloadWorkerArgs},
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs},
},
};
pub const JOB_KIND_WORKER: &str = "worker";
pub const JOB_KIND_TASK: &str = "task";
pub const JOB_STATUS_QUEUED: &str = "queued";
pub const JOB_STATUS_RUNNING: &str = "running";
pub const JOB_STATUS_SUCCEEDED: &str = "succeeded";
pub const JOB_STATUS_FAILED: &str = "failed";
pub const JOB_STATUS_CANCELLED: &str = "cancelled";
pub const WORKER_DOWNLOAD_MEDIA: &str = "worker.download_media";
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery";
pub const WORKER_AI_REINDEX: &str = "worker.ai_reindex";
pub const TASK_RETRY_DELIVERIES: &str = "task.retry_deliveries";
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_digest";
#[derive(Clone, Debug, Default)]
pub struct WorkerJobListQuery {
pub status: Option<String>,
pub job_kind: Option<String>,
pub worker_name: Option<String>,
pub search: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerCatalogEntry {
pub worker_name: String,
pub job_kind: String,
pub label: String,
pub description: String,
pub queue_name: Option<String>,
pub supports_cancel: bool,
pub supports_retry: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerStats {
pub worker_name: String,
pub job_kind: String,
pub label: String,
pub queued: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub cancelled: usize,
pub last_job_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerOverview {
pub total_jobs: usize,
pub queued: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub cancelled: usize,
pub active_jobs: usize,
pub worker_stats: Vec<WorkerStats>,
pub catalog: Vec<WorkerCatalogEntry>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerJobRecord {
pub created_at: String,
pub updated_at: String,
pub id: i32,
pub parent_job_id: Option<i32>,
pub job_kind: String,
pub worker_name: String,
pub display_name: Option<String>,
pub status: String,
pub queue_name: Option<String>,
pub requested_by: Option<String>,
pub requested_source: Option<String>,
pub trigger_mode: Option<String>,
pub payload: Option<Value>,
pub result: Option<Value>,
pub error_text: Option<String>,
pub tags: Option<Value>,
pub related_entity_type: Option<String>,
pub related_entity_id: Option<String>,
pub attempts_count: i32,
pub max_attempts: i32,
pub cancel_requested: bool,
pub queued_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub can_cancel: bool,
pub can_retry: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerJobListResult {
pub total: u64,
pub jobs: Vec<WorkerJobRecord>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerTaskDispatchResult {
pub queued: bool,
pub job: WorkerJobRecord,
}
#[derive(Clone, Debug)]
struct CreateWorkerJobInput {
parent_job_id: Option<i32>,
job_kind: String,
worker_name: String,
display_name: Option<String>,
queue_name: Option<String>,
requested_by: Option<String>,
requested_source: Option<String>,
trigger_mode: Option<String>,
payload: Option<Value>,
tags: Option<Value>,
related_entity_type: Option<String>,
related_entity_id: Option<String>,
max_attempts: i32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct RetryDeliveriesTaskPayload {
#[serde(default)]
limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct DigestTaskPayload {
period: String,
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn queue_name_for(worker_name: &str) -> Option<String> {
match worker_name {
WORKER_AI_REINDEX => Some("ai".to_string()),
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
TASK_RETRY_DELIVERIES => Some("maintenance".to_string()),
TASK_SEND_WEEKLY_DIGEST | TASK_SEND_MONTHLY_DIGEST => Some("digests".to_string()),
_ => None,
}
}
fn label_for(worker_name: &str) -> String {
match worker_name {
WORKER_AI_REINDEX => "AI 索引重建".to_string(),
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
TASK_RETRY_DELIVERIES => "重试待投递通知".to_string(),
TASK_SEND_WEEKLY_DIGEST => "发送周报".to_string(),
TASK_SEND_MONTHLY_DIGEST => "发送月报".to_string(),
_ => worker_name.to_string(),
}
}
fn description_for(worker_name: &str) -> String {
match worker_name {
WORKER_AI_REINDEX => "按当前站点内容重新生成 AI 检索索引,并分批写入向量数据。".to_string(),
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".to_string(),
TASK_SEND_WEEKLY_DIGEST => "根据近期内容生成周报,并为活跃订阅目标入队。".to_string(),
TASK_SEND_MONTHLY_DIGEST => "根据近期内容生成月报,并为活跃订阅目标入队。".to_string(),
_ => "后台异步任务。".to_string(),
}
}
fn tags_for(worker_name: &str) -> Value {
match worker_name {
WORKER_AI_REINDEX => json!(["ai", "reindex"]),
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
TASK_SEND_WEEKLY_DIGEST => json!(["digest", "weekly"]),
TASK_SEND_MONTHLY_DIGEST => json!(["digest", "monthly"]),
_ => json!([]),
}
}
fn can_cancel_status(status: &str, cancel_requested: bool) -> bool {
!cancel_requested && matches!(status, JOB_STATUS_QUEUED | JOB_STATUS_RUNNING)
}
fn can_retry_status(status: &str) -> bool {
matches!(
status,
JOB_STATUS_FAILED | JOB_STATUS_CANCELLED | JOB_STATUS_SUCCEEDED
)
}
fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
WorkerJobRecord {
created_at: item.created_at.to_rfc3339(),
updated_at: item.updated_at.to_rfc3339(),
id: item.id,
parent_job_id: item.parent_job_id,
job_kind: item.job_kind,
worker_name: item.worker_name,
display_name: item.display_name,
status: item.status.clone(),
queue_name: item.queue_name,
requested_by: item.requested_by,
requested_source: item.requested_source,
trigger_mode: item.trigger_mode,
payload: item.payload,
result: item.result,
error_text: item.error_text,
tags: item.tags,
related_entity_type: item.related_entity_type,
related_entity_id: item.related_entity_id,
attempts_count: item.attempts_count,
max_attempts: item.max_attempts,
cancel_requested: item.cancel_requested,
queued_at: item.queued_at,
started_at: item.started_at,
finished_at: item.finished_at,
can_cancel: can_cancel_status(&item.status, item.cancel_requested),
can_retry: can_retry_status(&item.status),
}
}
fn catalog_entries() -> Vec<WorkerCatalogEntry> {
[
(WORKER_AI_REINDEX, JOB_KIND_WORKER, true, true),
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, true, true),
(TASK_SEND_WEEKLY_DIGEST, JOB_KIND_TASK, true, true),
(TASK_SEND_MONTHLY_DIGEST, JOB_KIND_TASK, true, true),
]
.into_iter()
.map(
|(worker_name, job_kind, supports_cancel, supports_retry)| WorkerCatalogEntry {
worker_name: worker_name.to_string(),
job_kind: job_kind.to_string(),
label: label_for(worker_name),
description: description_for(worker_name),
queue_name: queue_name_for(worker_name),
supports_cancel,
supports_retry,
},
)
.collect()
}
async fn create_job(ctx: &AppContext, input: CreateWorkerJobInput) -> Result<worker_jobs::Model> {
Ok(worker_jobs::ActiveModel {
parent_job_id: Set(input.parent_job_id),
job_kind: Set(input.job_kind),
worker_name: Set(input.worker_name),
display_name: Set(trim_to_option(input.display_name)),
status: Set(JOB_STATUS_QUEUED.to_string()),
queue_name: Set(trim_to_option(input.queue_name)),
requested_by: Set(trim_to_option(input.requested_by)),
requested_source: Set(trim_to_option(input.requested_source)),
trigger_mode: Set(trim_to_option(input.trigger_mode)),
payload: Set(input.payload),
result: Set(None),
error_text: Set(None),
tags: Set(input.tags),
related_entity_type: Set(trim_to_option(input.related_entity_type)),
related_entity_id: Set(trim_to_option(input.related_entity_id)),
attempts_count: Set(0),
max_attempts: Set(input.max_attempts.max(1)),
cancel_requested: Set(false),
queued_at: Set(Some(now_rfc3339())),
started_at: Set(None),
finished_at: Set(None),
..Default::default()
}
.insert(&ctx.db)
.await?)
}
async fn find_job(ctx: &AppContext, id: i32) -> Result<worker_jobs::Model> {
worker_jobs::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)
}
async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) {
let worker = DownloadWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await {
tracing::warn!("download worker execution failed: {error}");
}
}
async fn dispatch_ai_reindex(args_ctx: AppContext, args: AiReindexWorkerArgs) {
let worker = AiReindexWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await {
tracing::warn!("ai reindex worker execution failed: {error}");
}
}
async fn dispatch_notification_delivery(
args_ctx: AppContext,
args: NotificationDeliveryWorkerArgs,
) {
let worker = NotificationDeliveryWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await {
tracing::warn!("notification delivery worker execution failed: {error}");
}
}
async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) -> Result<()> {
match DownloadWorker::perform_later(ctx, args.clone()).await {
Ok(_) => Ok(()),
Err(Error::QueueProviderMissing) => {
tokio::spawn(dispatch_download(ctx.clone(), args));
Ok(())
}
Err(error) => {
tracing::warn!(
"download worker queue unavailable, falling back to local task: {error}"
);
tokio::spawn(dispatch_download(ctx.clone(), args));
Ok(())
}
}
}
async fn enqueue_ai_reindex_worker(ctx: &AppContext, args: AiReindexWorkerArgs) -> Result<()> {
tokio::spawn(dispatch_ai_reindex(ctx.clone(), args));
Ok(())
}
async fn enqueue_notification_worker(
ctx: &AppContext,
args: NotificationDeliveryWorkerArgs,
) -> Result<()> {
match NotificationDeliveryWorker::perform_later(ctx, args.clone()).await {
Ok(_) => Ok(()),
Err(Error::QueueProviderMissing) => {
tokio::spawn(dispatch_notification_delivery(ctx.clone(), args));
Ok(())
}
Err(error) => {
tracing::warn!(
"notification worker queue unavailable, falling back to local task: {error}"
);
tokio::spawn(dispatch_notification_delivery(ctx.clone(), args));
Ok(())
}
}
}
async fn run_retry_deliveries_task(ctx: AppContext, job_id: i32, limit: Option<u64>) {
match begin_job_execution(&ctx, job_id).await {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
tracing::warn!("failed to start retry deliveries job #{job_id}: {error}");
return;
}
}
let result = async {
let effective_limit = limit.unwrap_or(60);
let queued = subscriptions::retry_due_deliveries(&ctx, effective_limit).await?;
mark_job_succeeded(
&ctx,
job_id,
Some(json!({
"limit": effective_limit,
"queued": queued,
})),
)
.await
}
.await;
if let Err(error) = result {
let _ = mark_job_failed(&ctx, job_id, error.to_string()).await;
}
}
async fn run_digest_task(ctx: AppContext, job_id: i32, period: String) {
match begin_job_execution(&ctx, job_id).await {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
tracing::warn!("failed to start digest job #{job_id}: {error}");
return;
}
}
let result = async {
let summary = subscriptions::send_digest(&ctx, &period).await?;
mark_job_succeeded(
&ctx,
job_id,
Some(json!({
"period": summary.period,
"post_count": summary.post_count,
"queued": summary.queued,
"skipped": summary.skipped,
})),
)
.await
}
.await;
if let Err(error) = result {
let _ = mark_job_failed(&ctx, job_id, error.to_string()).await;
}
}
pub async fn get_overview(ctx: &AppContext) -> Result<WorkerOverview> {
let items = worker_jobs::Entity::find()
.order_by(worker_jobs::Column::CreatedAt, Order::Desc)
.all(&ctx.db)
.await?;
let mut overview = WorkerOverview {
total_jobs: items.len(),
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
active_jobs: 0,
worker_stats: Vec::new(),
catalog: catalog_entries(),
};
let mut grouped = std::collections::BTreeMap::<String, WorkerStats>::new();
for item in items {
match item.status.as_str() {
JOB_STATUS_QUEUED => overview.queued += 1,
JOB_STATUS_RUNNING => overview.running += 1,
JOB_STATUS_SUCCEEDED => overview.succeeded += 1,
JOB_STATUS_FAILED => overview.failed += 1,
JOB_STATUS_CANCELLED => overview.cancelled += 1,
_ => {}
}
let entry = grouped
.entry(item.worker_name.clone())
.or_insert_with(|| WorkerStats {
worker_name: item.worker_name.clone(),
job_kind: item.job_kind.clone(),
label: label_for(&item.worker_name),
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
last_job_at: None,
});
match item.status.as_str() {
JOB_STATUS_QUEUED => entry.queued += 1,
JOB_STATUS_RUNNING => entry.running += 1,
JOB_STATUS_SUCCEEDED => entry.succeeded += 1,
JOB_STATUS_FAILED => entry.failed += 1,
JOB_STATUS_CANCELLED => entry.cancelled += 1,
_ => {}
}
if entry.last_job_at.is_none() {
entry.last_job_at = Some(item.created_at.to_rfc3339());
}
}
overview.active_jobs = overview.queued + overview.running;
overview.worker_stats = grouped.into_values().collect();
Ok(overview)
}
pub async fn list_jobs(ctx: &AppContext, query: WorkerJobListQuery) -> Result<WorkerJobListResult> {
let mut db_query =
worker_jobs::Entity::find().order_by(worker_jobs::Column::CreatedAt, Order::Desc);
if let Some(status) = query
.status
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(worker_jobs::Column::Status.eq(status));
}
if let Some(job_kind) = query
.job_kind
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(worker_jobs::Column::JobKind.eq(job_kind));
}
if let Some(worker_name) = query
.worker_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(worker_jobs::Column::WorkerName.eq(worker_name));
}
if let Some(search) = query
.search
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(
Condition::any()
.add(worker_jobs::Column::WorkerName.contains(search.clone()))
.add(worker_jobs::Column::DisplayName.contains(search.clone()))
.add(worker_jobs::Column::RelatedEntityId.contains(search.clone()))
.add(worker_jobs::Column::RelatedEntityType.contains(search)),
);
}
let total = db_query.clone().count(&ctx.db).await?;
let limit = query.limit.unwrap_or(120);
let items = db_query.limit(limit).all(&ctx.db).await?;
Ok(WorkerJobListResult {
total,
jobs: items.into_iter().map(to_job_record).collect(),
})
}
pub async fn get_job_record(ctx: &AppContext, id: i32) -> Result<WorkerJobRecord> {
Ok(to_job_record(find_job(ctx, id).await?))
}
pub async fn find_latest_job_by_related_entity(
ctx: &AppContext,
related_entity_type: &str,
related_entity_id: &str,
worker_name: Option<&str>,
) -> Result<Option<WorkerJobRecord>> {
let mut query = worker_jobs::Entity::find()
.filter(worker_jobs::Column::RelatedEntityType.eq(related_entity_type.to_string()))
.filter(worker_jobs::Column::RelatedEntityId.eq(related_entity_id.to_string()))
.order_by(worker_jobs::Column::CreatedAt, Order::Desc);
if let Some(worker_name) = worker_name.map(str::trim).filter(|value| !value.is_empty()) {
query = query.filter(worker_jobs::Column::WorkerName.eq(worker_name.to_string()));
}
Ok(query.one(&ctx.db).await?.map(to_job_record))
}
pub async fn begin_job_execution(ctx: &AppContext, id: i32) -> Result<bool> {
let item = find_job(ctx, id).await?;
if item.status == JOB_STATUS_CANCELLED {
return Ok(false);
}
if item.cancel_requested {
finish_job_cancelled(ctx, id, Some("job cancelled before execution".to_string())).await?;
return Ok(false);
}
let attempts_count = item.attempts_count + 1;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_RUNNING.to_string());
active.started_at = Set(Some(now_rfc3339()));
active.finished_at = Set(None);
active.error_text = Set(None);
active.result = Set(None);
active.attempts_count = Set(attempts_count);
let _ = active.update(&ctx.db).await?;
Ok(true)
}
pub async fn mark_job_succeeded(ctx: &AppContext, id: i32, result: Option<Value>) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_SUCCEEDED.to_string());
active.result = Set(result);
active.error_text = Set(None);
active.finished_at = Set(Some(now_rfc3339()));
active.update(&ctx.db).await?;
Ok(())
}
pub async fn update_job_result(ctx: &AppContext, id: i32, result: Value) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.result = Set(Some(result));
active.update(&ctx.db).await?;
Ok(())
}
pub async fn cancel_job_if_requested(ctx: &AppContext, id: i32, reason: &str) -> Result<bool> {
let item = find_job(ctx, id).await?;
if item.status == JOB_STATUS_CANCELLED {
return Ok(true);
}
if item.cancel_requested {
finish_job_cancelled(ctx, id, Some(reason.to_string())).await?;
return Ok(true);
}
Ok(false)
}
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_FAILED.to_string());
active.error_text = Set(Some(error_text));
active.finished_at = Set(Some(now_rfc3339()));
active.update(&ctx.db).await?;
Ok(())
}
pub async fn finish_job_cancelled(
ctx: &AppContext,
id: i32,
error_text: Option<String>,
) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_CANCELLED.to_string());
active.cancel_requested = Set(true);
active.finished_at = Set(Some(now_rfc3339()));
if error_text.is_some() {
active.error_text = Set(error_text);
}
active.update(&ctx.db).await?;
Ok(())
}
pub async fn request_cancel(ctx: &AppContext, id: i32) -> Result<WorkerJobRecord> {
let item = find_job(ctx, id).await?;
let mut active = item.clone().into_active_model();
active.cancel_requested = Set(true);
if item.status == JOB_STATUS_QUEUED {
active.status = Set(JOB_STATUS_CANCELLED.to_string());
active.finished_at = Set(Some(now_rfc3339()));
active.error_text = Set(Some("job cancelled before start".to_string()));
}
let updated = active.update(&ctx.db).await?;
Ok(to_job_record(updated))
}
pub async fn queue_download_job(
ctx: &AppContext,
args: &DownloadWorkerArgs,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let payload = serde_json::to_value(args)?;
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_WORKER.to_string(),
worker_name: WORKER_DOWNLOAD_MEDIA.to_string(),
display_name: Some(
args.title
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| format!("download {}", args.source_url)),
),
queue_name: queue_name_for(WORKER_DOWNLOAD_MEDIA),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(WORKER_DOWNLOAD_MEDIA)),
related_entity_type: Some("media_download".to_string()),
related_entity_id: Some(args.source_url.clone()),
max_attempts: 1,
},
)
.await?;
let mut worker_args = args.clone();
worker_args.job_id = Some(job.id);
enqueue_download_worker(ctx, worker_args).await?;
get_job_record(ctx, job.id).await
}
pub async fn queue_notification_delivery_job(
ctx: &AppContext,
delivery_id: i32,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let delivery = notification_deliveries::Entity::find_by_id(delivery_id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let mut delivery_active = delivery.clone().into_active_model();
delivery_active.status = Set(subscriptions::DELIVERY_STATUS_QUEUED.to_string());
delivery_active.response_text = Set(None);
delivery_active.next_retry_at = Set(None);
delivery_active.delivered_at = Set(None);
delivery_active.attempts_count = Set(0);
let delivery = delivery_active.update(&ctx.db).await?;
let base_args = NotificationDeliveryWorkerArgs {
delivery_id,
job_id: None,
};
let payload = serde_json::to_value(&base_args)?;
let display_name = format!("{}{}", delivery.event_type, delivery.target);
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_WORKER.to_string(),
worker_name: WORKER_NOTIFICATION_DELIVERY.to_string(),
display_name: Some(display_name),
queue_name: queue_name_for(WORKER_NOTIFICATION_DELIVERY),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(WORKER_NOTIFICATION_DELIVERY)),
related_entity_type: Some("notification_delivery".to_string()),
related_entity_id: Some(delivery_id.to_string()),
max_attempts: 1,
},
)
.await?;
let args = NotificationDeliveryWorkerArgs {
delivery_id,
job_id: Some(job.id),
};
enqueue_notification_worker(ctx, args).await?;
get_job_record(ctx, job.id).await
}
pub async fn queue_ai_reindex_job(
ctx: &AppContext,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let base_args = AiReindexWorkerArgs { job_id: None };
let payload = serde_json::to_value(&base_args)?;
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_WORKER.to_string(),
worker_name: WORKER_AI_REINDEX.to_string(),
display_name: Some("重建 AI 索引".to_string()),
queue_name: queue_name_for(WORKER_AI_REINDEX),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(WORKER_AI_REINDEX)),
related_entity_type: Some("ai_index".to_string()),
related_entity_id: Some("site".to_string()),
max_attempts: 1,
},
)
.await?;
enqueue_ai_reindex_worker(
ctx,
AiReindexWorkerArgs {
job_id: Some(job.id),
},
)
.await?;
get_job_record(ctx, job.id).await
}
pub async fn spawn_retry_deliveries_task(
ctx: &AppContext,
limit: Option<u64>,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let payload = serde_json::to_value(RetryDeliveriesTaskPayload { limit })?;
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_TASK.to_string(),
worker_name: TASK_RETRY_DELIVERIES.to_string(),
display_name: Some("重试待投递通知".to_string()),
queue_name: queue_name_for(TASK_RETRY_DELIVERIES),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(TASK_RETRY_DELIVERIES)),
related_entity_type: Some("notification_delivery".to_string()),
related_entity_id: None,
max_attempts: 1,
},
)
.await?;
tokio::spawn(run_retry_deliveries_task(ctx.clone(), job.id, limit));
get_job_record(ctx, job.id).await
}
pub async fn spawn_digest_task(
ctx: &AppContext,
period: &str,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let normalized_period = match period.trim().to_ascii_lowercase().as_str() {
"monthly" => "monthly",
_ => "weekly",
}
.to_string();
let payload = serde_json::to_value(DigestTaskPayload {
period: normalized_period.clone(),
})?;
let worker_name = if normalized_period == "monthly" {
TASK_SEND_MONTHLY_DIGEST
} else {
TASK_SEND_WEEKLY_DIGEST
};
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_TASK.to_string(),
worker_name: worker_name.to_string(),
display_name: Some(if normalized_period == "monthly" {
"发送月报".to_string()
} else {
"发送周报".to_string()
}),
queue_name: queue_name_for(worker_name),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(worker_name)),
related_entity_type: Some("subscription_digest".to_string()),
related_entity_id: Some(normalized_period.clone()),
max_attempts: 1,
},
)
.await?;
tokio::spawn(run_digest_task(ctx.clone(), job.id, normalized_period));
get_job_record(ctx, job.id).await
}
pub async fn retry_job(
ctx: &AppContext,
id: i32,
requested_by: Option<String>,
requested_source: Option<String>,
) -> Result<WorkerJobRecord> {
let item = find_job(ctx, id).await?;
let payload = item.payload.clone().unwrap_or(Value::Null);
match item.worker_name.as_str() {
WORKER_AI_REINDEX => {
let _ = serde_json::from_value::<AiReindexWorkerArgs>(payload)?;
queue_ai_reindex_job(
ctx,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
WORKER_DOWNLOAD_MEDIA => {
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
queue_download_job(
ctx,
&args,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
WORKER_NOTIFICATION_DELIVERY => {
let args = serde_json::from_value::<NotificationDeliveryWorkerArgs>(payload)?;
queue_notification_delivery_job(
ctx,
args.delivery_id,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
TASK_RETRY_DELIVERIES => {
let args = serde_json::from_value::<RetryDeliveriesTaskPayload>(payload)?;
spawn_retry_deliveries_task(
ctx,
args.limit,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
TASK_SEND_WEEKLY_DIGEST | TASK_SEND_MONTHLY_DIGEST => {
let args = serde_json::from_value::<DigestTaskPayload>(payload)?;
spawn_digest_task(
ctx,
&args.period,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
_ => Err(Error::BadRequest(format!(
"不支持重试任务:{}",
item.worker_name
))),
}
}

View File

@@ -0,0 +1,77 @@
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::services::{ai, worker_jobs};
pub struct AiReindexWorker {
pub ctx: AppContext,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AiReindexWorkerArgs {
#[serde(default)]
pub job_id: Option<i32>,
}
#[async_trait]
impl BackgroundWorker<AiReindexWorkerArgs> for AiReindexWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
fn tags() -> Vec<String> {
vec!["ai".to_string(), "reindex".to_string()]
}
async fn perform(&self, args: AiReindexWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
return Ok(());
}
match ai::rebuild_index(&self.ctx, Some(job_id)).await {
Ok(summary) => {
worker_jobs::mark_job_succeeded(
&self.ctx,
job_id,
Some(serde_json::json!({
"phase": "completed",
"message": "AI 索引重建完成。",
"progress": {
"phase": "completed",
"message": "AI 索引重建完成。",
"total_chunks": summary.indexed_chunks,
"processed_chunks": summary.indexed_chunks,
"total_batches": summary.indexed_chunks.div_ceil(ai::REINDEX_EMBEDDING_BATCH_SIZE.max(1)),
"current_batch": summary.indexed_chunks.div_ceil(ai::REINDEX_EMBEDDING_BATCH_SIZE.max(1)),
"batch_size": ai::REINDEX_EMBEDDING_BATCH_SIZE.max(1),
"percent": 100,
},
"indexed_chunks": summary.indexed_chunks,
"last_indexed_at": summary.last_indexed_at.map(|value| value.to_rfc3339()),
})),
)
.await?;
Ok(())
}
Err(error) => {
if worker_jobs::cancel_job_if_requested(
&self.ctx,
job_id,
"job cancelled during reindex",
)
.await?
{
return Ok(());
}
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error)
}
}
} else {
ai::rebuild_index(&self.ctx, None).await?;
Ok(())
}
}
}

View File

@@ -1,13 +1,355 @@
use std::io::Cursor;
use image::{ImageFormat, load_from_memory};
use loco_rs::prelude::*;
use reqwest::{Url, header, redirect::Policy};
use serde::{Deserialize, Serialize};
use crate::services::{media_assets, storage, worker_jobs};
pub struct DownloadWorker {
pub ctx: AppContext,
}
#[derive(Deserialize, Debug, Serialize)]
#[derive(Clone, Deserialize, Debug, Serialize)]
pub struct DownloadWorkerArgs {
pub user_guid: String,
pub source_url: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub target_format: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default)]
pub caption: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub job_id: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DownloadedMediaObject {
pub key: String,
pub url: String,
pub size_bytes: i64,
pub source_url: String,
pub content_type: Option<String>,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn normalize_prefix(value: Option<String>) -> String {
value
.unwrap_or_else(|| "uploads".to_string())
.trim()
.trim_matches('/')
.to_string()
}
pub fn normalize_target_format(value: Option<String>) -> Result<Option<String>> {
let Some(value) = value.map(|item| item.trim().to_ascii_lowercase()) else {
return Ok(None);
};
if value.is_empty() || value == "original" {
return Ok(None);
}
match value.as_str() {
"webp" | "avif" => Ok(Some(value)),
_ => Err(Error::BadRequest(
"target_format 仅支持 webp、avif 或 original".to_string(),
)),
}
}
fn derive_file_name(url: &Url) -> Option<String> {
url.path_segments()
.and_then(|segments| segments.last())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn infer_extension(file_name: Option<&str>, content_type: Option<&str>) -> Option<String> {
let from_name = file_name
.and_then(|name| name.rsplit('.').next())
.map(str::trim)
.filter(|ext| !ext.is_empty())
.map(str::to_ascii_lowercase);
if let Some(ext) = from_name
.as_deref()
.filter(|ext| ext.chars().all(|ch| ch.is_ascii_alphanumeric()) && ext.len() <= 10)
{
return Some(ext.to_string());
}
match content_type
.unwrap_or_default()
.trim()
.split(';')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"image/png" => Some("png".to_string()),
"image/jpeg" => Some("jpg".to_string()),
"image/webp" => Some("webp".to_string()),
"image/gif" => Some("gif".to_string()),
"image/avif" => Some("avif".to_string()),
"image/svg+xml" => Some("svg".to_string()),
"application/pdf" => Some("pdf".to_string()),
_ => None,
}
}
fn is_supported_content_type(value: Option<&str>) -> bool {
value
.unwrap_or_default()
.trim()
.split(';')
.next()
.map(|item| {
matches!(
item,
"image/png"
| "image/jpeg"
| "image/webp"
| "image/gif"
| "image/avif"
| "image/svg+xml"
| "application/pdf"
)
})
.unwrap_or(false)
}
fn is_convertible_bitmap_content_type(value: Option<&str>) -> bool {
value
.unwrap_or_default()
.trim()
.split(';')
.next()
.map(|item| {
matches!(
item,
"image/png" | "image/jpeg" | "image/webp" | "image/avif"
)
})
.unwrap_or(false)
}
fn target_mime_type(target_format: &str) -> Option<&'static str> {
match target_format {
"webp" => Some("image/webp"),
"avif" => Some("image/avif"),
_ => None,
}
}
fn convert_media_bytes(
bytes: &[u8],
content_type: Option<&str>,
target_format: &str,
) -> Result<(Vec<u8>, String, String)> {
let target_mime = target_mime_type(target_format)
.ok_or_else(|| Error::BadRequest("不支持的目标媒体格式".to_string()))?;
if !is_convertible_bitmap_content_type(content_type) {
return Err(Error::BadRequest(
"当前仅支持把 PNG / JPEG / WebP / AVIF 转成 WebP 或 AVIF".to_string(),
));
}
let image = load_from_memory(bytes)
.map_err(|error| Error::BadRequest(format!("解析远程图片失败: {error}")))?;
let image_format = match target_format {
"webp" => ImageFormat::WebP,
"avif" => ImageFormat::Avif,
_ => return Err(Error::BadRequest("不支持的目标媒体格式".to_string())),
};
let mut cursor = Cursor::new(Vec::new());
image
.write_to(&mut cursor, image_format)
.map_err(|error| Error::BadRequest(format!("转换远程图片格式失败: {error}")))?;
Ok((
cursor.into_inner(),
target_format.to_string(),
target_mime.to_string(),
))
}
fn default_title(args: &DownloadWorkerArgs, file_name: Option<&str>) -> String {
trim_to_option(args.title.clone())
.or_else(|| {
file_name.map(|value| {
value
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(value)
.replace(['-', '_'], " ")
.trim()
.to_string()
})
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "remote asset".to_string())
}
fn merge_notes(notes: Option<String>, source_url: &str) -> Option<String> {
let note = notes.unwrap_or_default().trim().to_string();
let source_line = format!("source_url: {source_url}");
if note.is_empty() {
return Some(source_line);
}
if note.contains(&source_line) {
return Some(note);
}
Some(format!("{note}\n{source_line}"))
}
pub async fn download_media_to_storage(
ctx: &AppContext,
args: &DownloadWorkerArgs,
) -> Result<DownloadedMediaObject> {
let source_url = trim_to_option(Some(args.source_url.clone()))
.ok_or_else(|| Error::BadRequest("source_url 不能为空".to_string()))?;
let parsed_url = Url::parse(&source_url)
.map_err(|_| Error::BadRequest("source_url 必须是合法的绝对 URL".to_string()))?;
let client = reqwest::Client::builder()
.redirect(Policy::limited(5))
.build()
.map_err(|error| Error::BadRequest(format!("初始化下载客户端失败: {error}")))?;
let response = client
.get(parsed_url.clone())
.send()
.await
.map_err(|error| Error::BadRequest(format!("下载远程媒体失败: {error}")))?;
if !response.status().is_success() {
return Err(Error::BadRequest(format!(
"下载远程媒体失败,状态码:{}",
response.status()
)));
}
let final_url = response.url().clone();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
if !is_supported_content_type(content_type.as_deref()) {
return Err(Error::BadRequest(
"仅支持图片或 PDF 资源的远程抓取".to_string(),
));
}
let bytes = response
.bytes()
.await
.map_err(|error| Error::BadRequest(format!("读取远程媒体内容失败: {error}")))?;
if bytes.is_empty() {
return Err(Error::BadRequest("下载到的远程媒体内容为空".to_string()));
}
let file_name = derive_file_name(&final_url);
let target_format = normalize_target_format(args.target_format.clone())?;
let normalized_source_content_type = content_type
.as_deref()
.map(str::trim)
.and_then(|value| value.split(';').next())
.map(str::to_ascii_lowercase);
let already_target_format = target_format
.as_deref()
.and_then(target_mime_type)
.zip(normalized_source_content_type.as_deref())
.map(|(target_mime, source_mime)| source_mime == target_mime)
.unwrap_or(false);
let (payload_bytes, extension, resolved_content_type) =
if let Some(target_format) = target_format.as_deref() {
if already_target_format {
(
bytes.to_vec(),
target_format.to_string(),
target_mime_type(target_format)
.unwrap_or_default()
.to_string(),
)
} else {
convert_media_bytes(&bytes, content_type.as_deref(), target_format)?
}
} else {
(
bytes.to_vec(),
infer_extension(file_name.as_deref(), content_type.as_deref())
.ok_or_else(|| Error::BadRequest("无法识别远程媒体文件类型".to_string()))?,
content_type
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string()),
)
};
let prefix = normalize_prefix(args.prefix.clone());
let object_key = storage::build_object_key(
&prefix,
&default_title(args, file_name.as_deref()),
&extension,
);
let stored = storage::upload_bytes_to_r2(
ctx,
&object_key,
payload_bytes.clone(),
Some(resolved_content_type.as_str()),
Some("public, max-age=31536000, immutable"),
)
.await?;
media_assets::upsert_by_key(
ctx,
&stored.key,
media_assets::MediaAssetMetadataInput {
title: trim_to_option(args.title.clone())
.or_else(|| trim_to_option(Some(default_title(args, file_name.as_deref())))),
alt_text: trim_to_option(args.alt_text.clone()),
caption: trim_to_option(args.caption.clone()),
tags: (!args.tags.is_empty()).then_some(args.tags.clone()),
notes: merge_notes(args.notes.clone(), final_url.as_str()),
},
)
.await?;
Ok(DownloadedMediaObject {
key: stored.key,
url: stored.url,
size_bytes: payload_bytes.len() as i64,
source_url: final_url.to_string(),
content_type: Some(resolved_content_type),
})
}
#[async_trait]
@@ -15,9 +357,31 @@ impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> {
// TODO: Some actual work goes here...
Ok(())
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
return Ok(());
}
match download_media_to_storage(&self.ctx, &args).await {
Ok(downloaded) => {
worker_jobs::mark_job_succeeded(
&self.ctx,
job_id,
Some(serde_json::to_value(downloaded)?),
)
.await?;
Ok(())
}
Err(error) => {
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error)
}
}
} else {
download_media_to_storage(&self.ctx, &args).await?;
Ok(())
}
}
}

View File

@@ -1,2 +1,3 @@
pub mod ai_reindex;
pub mod downloader;
pub mod notification_delivery;

View File

@@ -1,7 +1,7 @@
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::services::subscriptions;
use crate::services::{subscriptions, worker_jobs};
pub struct NotificationDeliveryWorker {
pub ctx: AppContext,
@@ -10,6 +10,8 @@ pub struct NotificationDeliveryWorker {
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NotificationDeliveryWorkerArgs {
pub delivery_id: i32,
#[serde(default)]
pub job_id: Option<i32>,
}
#[async_trait]
@@ -18,11 +20,29 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
Self { ctx: ctx.clone() }
}
fn tags() -> Vec<String> {
vec!["notifications".to_string()]
}
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
subscriptions::process_delivery(&self.ctx, args.delivery_id).await
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
return Ok(());
}
match subscriptions::process_delivery(&self.ctx, args.delivery_id).await {
Ok(_) => {
worker_jobs::mark_job_succeeded(
&self.ctx,
job_id,
Some(serde_json::json!({ "delivery_id": args.delivery_id })),
)
.await?;
Ok(())
}
Err(error) => {
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error)
}
}
} else {
subscriptions::process_delivery(&self.ctx, args.delivery_id).await
}
}
}

View File

@@ -1,4 +1,4 @@
use chrono::{offset::Local, Duration};
use chrono::{Duration, offset::Local};
use insta::assert_debug_snapshot;
use loco_rs::testing::prelude::*;
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};

View File

@@ -1,5 +1,5 @@
use axum::http::{HeaderName, HeaderValue};
use loco_rs::{app::AppContext, TestServer};
use loco_rs::{TestServer, app::AppContext};
use termi_api::{models::users, views::auth::LoginResponse};
const USER_EMAIL: &str = "test@loco.com";

View File

@@ -3,6 +3,17 @@ BACKEND_PORT=5150
FRONTEND_PORT=4321
ADMIN_PORT=4322
# 建议在小内存主机上给每个服务设置明确上限,避免 backend 在 AI 重建索引时
# 把整台主机拖进 swap 抖动。默认值与 compose.package.yml 保持一致。
BACKEND_MEMORY_LIMIT=768m
BACKEND_MEMORY_SWAP_LIMIT=768m
BACKEND_WORKER_MEMORY_LIMIT=1g
BACKEND_WORKER_MEMORY_SWAP_LIMIT=1g
FRONTEND_MEMORY_LIMIT=256m
FRONTEND_MEMORY_SWAP_LIMIT=256m
ADMIN_MEMORY_LIMIT=128m
ADMIN_MEMORY_SWAP_LIMIT=128m
# frontend SSR 服务端访问 backend 用这个内部地址compose 默认可直接使用)
INTERNAL_API_BASE_URL=http://backend:5150/api
@@ -18,6 +29,10 @@ PUBLIC_API_BASE_URL=
# PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev
PUBLIC_IMAGE_ALLOWED_HOSTS=
# 如果你要启用 IndexNow 自动提交,请填写一个你自己的 key。
# frontend 会在 /indexnow-key.txt 暴露这个 key配合 `pnpm indexnow:submit` 使用。
INDEXNOW_KEY=
# admin 浏览器请求 backend API 优先读取这个公开地址。
# 如果留空admin 会在生产环境按“当前访问主机 + :5150”回退。
# 如果你采用推荐方案admin 域名同域转发 /api 到 backend

View File

@@ -74,6 +74,11 @@ backend-worker
如果只启动 `backend` 而没有 `backend-worker`,通知会入队但没人消费。
补充说明:
- `backend-worker` 目前主要消费 Redis 队列里的通知相关任务。
- AI 索引重建会直接在 `backend` 进程本地启动,这样创建任务后会立即进入执行,不再依赖独立 worker 消费。
## 2.1 推荐的后台认证链路
当前最推荐:

View File

@@ -43,6 +43,10 @@ python deploy/scripts/render_compose_env.py \
建议在 `config.yaml -> compose_env` 下同时检查这些运行时变量:
- `BACKEND_MEMORY_LIMIT / BACKEND_MEMORY_SWAP_LIMIT`backend 容器内存 / swap 上限;对小内存主机建议显式设置
- `BACKEND_WORKER_MEMORY_LIMIT / BACKEND_WORKER_MEMORY_SWAP_LIMIT`worker 容器内存 / swap 上限
- `FRONTEND_MEMORY_LIMIT / FRONTEND_MEMORY_SWAP_LIMIT`frontend 容器内存 / swap 上限
- `ADMIN_MEMORY_LIMIT / ADMIN_MEMORY_SWAP_LIMIT`admin 容器内存 / swap 上限
- `INTERNAL_API_BASE_URL`frontend SSR 容器访问 backend 用compose 默认推荐 `http://backend:5150/api`
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`
- `PUBLIC_COMMENT_TURNSTILE_SITE_KEY`:前台评论 / 订阅表单使用的 Cloudflare Turnstile site key
@@ -62,6 +66,14 @@ python deploy/scripts/render_compose_env.py \
```yaml
compose_env:
BACKEND_MEMORY_LIMIT: 768m
BACKEND_MEMORY_SWAP_LIMIT: 768m
BACKEND_WORKER_MEMORY_LIMIT: 1g
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 1g
FRONTEND_MEMORY_LIMIT: 256m
FRONTEND_MEMORY_SWAP_LIMIT: 256m
ADMIN_MEMORY_LIMIT: 128m
ADMIN_MEMORY_SWAP_LIMIT: 128m
PUBLIC_API_BASE_URL: https://api.blog.init.cool
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
@@ -135,7 +147,8 @@ A: 当前站点对外内容页优先 SEO 与首屏可见性,保留 SSR 更稳
### Q4: 生产推荐端口设计是什么?
A: 推荐前置 Caddy/Nginx 统一暴露 `80/443``frontend:4321` / `backend:5150` / `admin:80` 仅走内网。
当前 `compose.package.yml` 属于直连端口版,便于快速部署与联调。
另外因为通知已经走异步队列,生产务必同时启动 `backend-worker`
另外因为通知已经走异步队列,生产务必同时启动 `backend-worker`
AI 索引重建当前直接在 `backend` 进程本地启动,不依赖 `backend-worker` 消费 Redis 队列。
### Q5: 为什么 compose 里没看到 `ADMIN_VITE_FRONTEND_BASE_URL`
A:
@@ -178,6 +191,7 @@ A:
A:
- `backend` 镜像启动时会先执行 `db migrate`
- `backend` 提供 `/healthz`
- `backend-worker` 不提供 HTTP `/healthz`compose 会覆盖镜像默认 healthcheck改为检查主进程是否仍以 `--worker` 模式运行
- `frontend` 提供 `/healthz`
- `admin` 继续由 Nginx 提供 `/healthz`
- compose 现在使用 `depends_on.condition: service_healthy`

View File

@@ -3,6 +3,10 @@ services:
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
pull_policy: always
restart: unless-stopped
# 对 tohka 这类小内存主机,建议给服务设置明确上限,
# 避免 AI 重建索引时把整机拖进 swap 抖动 / OOM。
mem_limit: ${BACKEND_MEMORY_LIMIT:-768m}
memswap_limit: ${BACKEND_MEMORY_SWAP_LIMIT:-768m}
environment:
PORT: 5150
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5150}
@@ -30,6 +34,8 @@ services:
image: ${BACKEND_IMAGE:-git.init.cool/cool/termi-astro-backend:latest}
pull_policy: always
restart: unless-stopped
mem_limit: ${BACKEND_WORKER_MEMORY_LIMIT:-1g}
memswap_limit: ${BACKEND_WORKER_MEMORY_SWAP_LIMIT:-1g}
depends_on:
backend:
condition: service_healthy
@@ -48,11 +54,22 @@ services:
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
RUST_LOG: ${RUST_LOG:-info}
TERMI_SKIP_MIGRATIONS: 'true'
# backend 镜像默认 healthcheck 会探测 HTTP /healthz
# 但 worker 模式不监听 5150所以这里改成“主进程仍然是 --worker”检查。
healthcheck:
test:
['CMD-SHELL', "test -r /proc/1/cmdline && tr '\\000' ' ' </proc/1/cmdline | grep -q -- '--worker'"]
interval: 30s
timeout: 3s
start_period: 15s
retries: 5
frontend:
image: ${FRONTEND_IMAGE:-git.init.cool/cool/termi-astro-frontend:latest}
pull_policy: always
restart: unless-stopped
mem_limit: ${FRONTEND_MEMORY_LIMIT:-256m}
memswap_limit: ${FRONTEND_MEMORY_SWAP_LIMIT:-256m}
depends_on:
backend:
condition: service_healthy
@@ -68,6 +85,7 @@ services:
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: ${PUBLIC_COMMENT_TURNSTILE_SITE_KEY:-}
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: ${PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY:-}
PUBLIC_IMAGE_ALLOWED_HOSTS: ${PUBLIC_IMAGE_ALLOWED_HOSTS:-}
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# frontend 是 Astro SSR(Node) 服务,容器内部监听 4321
# 生产建议由网关统一反代,仅对外开放 80/443
ports:
@@ -77,6 +95,8 @@ services:
image: ${ADMIN_IMAGE:-git.init.cool/cool/termi-astro-admin:latest}
pull_policy: always
restart: unless-stopped
mem_limit: ${ADMIN_MEMORY_LIMIT:-128m}
memswap_limit: ${ADMIN_MEMORY_SWAP_LIMIT:-128m}
depends_on:
backend:
condition: service_healthy

View File

@@ -25,6 +25,14 @@ compose_env:
BACKEND_PORT: 5150
FRONTEND_PORT: 4321
ADMIN_PORT: 4322
BACKEND_MEMORY_LIMIT: 768m
BACKEND_MEMORY_SWAP_LIMIT: 768m
BACKEND_WORKER_MEMORY_LIMIT: 1g
BACKEND_WORKER_MEMORY_SWAP_LIMIT: 1g
FRONTEND_MEMORY_LIMIT: 256m
FRONTEND_MEMORY_SWAP_LIMIT: 256m
ADMIN_MEMORY_LIMIT: 128m
ADMIN_MEMORY_SWAP_LIMIT: 128m
APP_BASE_URL: https://admin.blog.init.cool
INTERNAL_API_BASE_URL: http://backend:5150/api

View File

@@ -105,8 +105,8 @@ function Start-Backend {
-Run {
$env:DATABASE_URL = $DatabaseUrl
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green
cargo loco start 2>&1
Write-Host "[backend] Starting Loco.rs server + worker..." -ForegroundColor Green
cargo loco start --server-and-worker 2>&1
}
}

View File

@@ -13,13 +13,17 @@ COPY . .
ARG PUBLIC_API_BASE_URL=http://localhost:5150/api
ENV PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL}
RUN pnpm build
RUN pnpm build \
&& pnpm prune --prod
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321

View File

@@ -58,3 +58,27 @@ admin 侧上传封面时也会额外做:
- 上传前压缩
- 16:9 封面规范化
- 优先转为 `AVIF / WebP`
## GEO / AI 搜索补充
前台现在额外提供:
- `/llms.txt`
- `/llms-full.txt`
- `/indexnow-key.txt`(仅在配置 `INDEXNOW_KEY` 时可用)
如果你想在发布后主动推送 IndexNow可以配置
```env
INDEXNOW_KEY=your-indexnow-key
SITE_URL=https://your-frontend.example.com
PUBLIC_API_BASE_URL=https://your-frontend.example.com/api
```
然后运行:
```powershell
pnpm indexnow:submit
```
脚本会自动收集首页、文章、分类、标签、评测等 canonical URL 并提交到 IndexNow。

View File

@@ -9,7 +9,8 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"indexnow:submit": "node ./scripts/submit-indexnow.mjs"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.0.1",
@@ -21,6 +22,7 @@
"autoprefixer": "^10.4.27",
"lucide-astro": "^0.556.0",
"postcss": "^8.5.8",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"svelte": "^5.55.0",
"tailwindcss": "^3.4.19"

Some files were not shown because too many files have changed in this diff Show More