Compare commits
1 Commits
36d505ece6
...
codex/ui-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 73c261eca4 |
@@ -18,121 +18,7 @@ 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
|
||||
@@ -153,48 +39,10 @@ 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:
|
||||
@@ -255,8 +103,6 @@ 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}"
|
||||
@@ -264,7 +110,6 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login registry
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }}
|
||||
@@ -319,7 +164,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup docker buildx
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -333,7 +177,6 @@ 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 }}
|
||||
@@ -348,7 +191,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
@@ -358,8 +200,6 @@ 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 }}
|
||||
@@ -382,12 +222,8 @@ 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}" \
|
||||
@@ -396,7 +232,6 @@ jobs:
|
||||
"${CONTEXT_DIR}"
|
||||
|
||||
- name: Output image tags
|
||||
if: steps.should_build.outputs.should_build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
@@ -409,57 +244,3 @@ 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
|
||||
|
||||
@@ -35,6 +35,11 @@ jobs:
|
||||
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
|
||||
@@ -56,19 +61,6 @@ jobs:
|
||||
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: Prepare Playwright artifact folders
|
||||
run: |
|
||||
rm -rf playwright-smoke/.artifacts
|
||||
@@ -79,8 +71,6 @@ jobs:
|
||||
id: ui_frontend
|
||||
working-directory: playwright-smoke
|
||||
continue-on-error: true
|
||||
env:
|
||||
PLAYWRIGHT_USE_BUILT_APP: '1'
|
||||
run: pnpm test:frontend
|
||||
|
||||
- name: Collect frontend Playwright artifacts
|
||||
@@ -98,8 +88,6 @@ jobs:
|
||||
id: ui_admin
|
||||
working-directory: playwright-smoke
|
||||
continue-on-error: true
|
||||
env:
|
||||
PLAYWRIGHT_USE_BUILT_APP: '1'
|
||||
run: pnpm test:admin
|
||||
|
||||
- name: Collect admin Playwright artifacts
|
||||
@@ -112,27 +100,67 @@ jobs:
|
||||
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
|
||||
fi
|
||||
|
||||
- name: Summarize Playwright artifact paths
|
||||
- name: Upload frontend HTML report
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-html-report-frontend
|
||||
path: playwright-smoke/.artifacts/frontend/playwright-report
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
echo "Gitea Actions 当前不支持 actions/upload-artifact@v4,改为直接输出产物目录:"
|
||||
- name: Upload admin HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-html-report-admin
|
||||
path: playwright-smoke/.artifacts/admin/playwright-report
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
for path in \
|
||||
"playwright-smoke/.artifacts/frontend/playwright-report" \
|
||||
"playwright-smoke/.artifacts/frontend/test-results" \
|
||||
"playwright-smoke/.artifacts/admin/playwright-report" \
|
||||
"playwright-smoke/.artifacts/admin/test-results"
|
||||
do
|
||||
if [ -d "${path}" ]; then
|
||||
echo "- ${path}"
|
||||
find "${path}" -maxdepth 2 -type f | sort | head -n 20
|
||||
else
|
||||
echo "- ${path} (missing)"
|
||||
fi
|
||||
done
|
||||
- name: Upload frontend raw results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-raw-results-frontend
|
||||
path: playwright-smoke/.artifacts/frontend/test-results
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload admin raw results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-raw-results-admin
|
||||
path: playwright-smoke/.artifacts/admin/test-results
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload frontend failure screenshots / videos / traces
|
||||
if: steps.ui_frontend.outcome != 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-failure-artifacts-frontend
|
||||
path: |
|
||||
playwright-smoke/.artifacts/frontend/test-results/**/*.png
|
||||
playwright-smoke/.artifacts/frontend/test-results/**/*.webm
|
||||
playwright-smoke/.artifacts/frontend/test-results/**/*.zip
|
||||
playwright-smoke/.artifacts/frontend/test-results/**/error-context.md
|
||||
retention-days: 21
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload admin failure screenshots / videos / traces
|
||||
if: steps.ui_admin.outcome != 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-failure-artifacts-admin
|
||||
path: |
|
||||
playwright-smoke/.artifacts/admin/test-results/**/*.png
|
||||
playwright-smoke/.artifacts/admin/test-results/**/*.webm
|
||||
playwright-smoke/.artifacts/admin/test-results/**/*.zip
|
||||
playwright-smoke/.artifacts/admin/test-results/**/error-context.md
|
||||
retention-days: 21
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Mark workflow failed when any suite failed
|
||||
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
|
||||
|
||||
28
README.md
28
README.md
@@ -22,7 +22,7 @@ Monorepo for the Termi blog system.
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
pnpm dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs.
|
||||
@@ -30,14 +30,13 @@ This starts `frontend + admin + backend` in a single Windows Terminal window wit
|
||||
Common shortcuts:
|
||||
|
||||
```powershell
|
||||
pnpm dev:mcp
|
||||
pnpm dev:frontend
|
||||
pnpm dev:admin
|
||||
pnpm dev:backend
|
||||
pnpm dev:mcp-only
|
||||
pnpm stop
|
||||
pnpm restart
|
||||
pnpm test:ui
|
||||
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
|
||||
```
|
||||
|
||||
### PowerShell entrypoint
|
||||
@@ -143,7 +142,6 @@ 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)
|
||||
@@ -154,16 +152,6 @@ 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
|
||||
|
||||
|
||||
@@ -38,18 +38,6 @@ 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 }
|
||||
@@ -86,18 +74,14 @@ const SiteSettingsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/site-settings-page')
|
||||
return { default: mod.SiteSettingsPage }
|
||||
})
|
||||
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 }
|
||||
})
|
||||
const SubscriptionsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/subscriptions-page')
|
||||
return { default: mod.SubscriptionsPage }
|
||||
})
|
||||
|
||||
type SessionContextValue = {
|
||||
session: AdminSessionResponse
|
||||
@@ -239,56 +223,6 @@ 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={
|
||||
@@ -393,14 +327,6 @@ function AppRoutes() {
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workers"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<WorkersPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit"
|
||||
element={
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Settings,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
@@ -100,17 +99,11 @@ const primaryNav = [
|
||||
description: '邮件 / Webhook 推送',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
to: '/workers',
|
||||
label: 'Workers',
|
||||
description: '异步任务 / 队列控制台',
|
||||
icon: Workflow,
|
||||
},
|
||||
{
|
||||
to: '/audit',
|
||||
label: '审计',
|
||||
description: '后台操作日志与排障线索',
|
||||
icon: History,
|
||||
description: '后台操作审计日志',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NativeSelectProps = React.ComponentProps<'select'> & {
|
||||
'data-testid'?: string
|
||||
}
|
||||
type NativeSelectProps = React.ComponentProps<'select'>
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
@@ -80,11 +78,8 @@ 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,
|
||||
@@ -139,14 +134,12 @@ 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 maxAllowedWidth = window.innerWidth - viewportPadding * 2
|
||||
const width = Math.min(Math.max(rect.width, minMenuWidth), maxAllowedWidth)
|
||||
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
|
||||
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
|
||||
|
||||
setMenuPlacement(openToTop ? 'top' : 'bottom')
|
||||
@@ -441,9 +434,6 @@ 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}
|
||||
@@ -464,11 +454,8 @@ 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) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
AdminAiProviderTestResponse,
|
||||
AdminImageUploadResponse,
|
||||
AdminMediaBatchDeleteResponse,
|
||||
AdminMediaDownloadResponse,
|
||||
AdminMediaDeleteResponse,
|
||||
AdminMediaListResponse,
|
||||
AdminMediaMetadataResponse,
|
||||
@@ -13,7 +12,6 @@ import type {
|
||||
AdminMediaUploadResponse,
|
||||
AdminPostCoverImageRequest,
|
||||
AdminPostCoverImageResponse,
|
||||
AdminPostLocalizeImagesResponse,
|
||||
AdminDashboardResponse,
|
||||
AdminPostMetadataResponse,
|
||||
AdminPostPolishResponse,
|
||||
@@ -38,7 +36,6 @@ import type {
|
||||
MarkdownDocumentResponse,
|
||||
MarkdownImportResponse,
|
||||
MediaAssetMetadataPayload,
|
||||
MediaDownloadPayload,
|
||||
NotificationDeliveryRecord,
|
||||
PostPageResponse,
|
||||
PostListQuery,
|
||||
@@ -56,10 +53,6 @@ import type {
|
||||
SubscriptionPayload,
|
||||
SubscriptionRecord,
|
||||
SubscriptionUpdatePayload,
|
||||
WorkerJobListResponse,
|
||||
WorkerJobRecord,
|
||||
WorkerOverview,
|
||||
WorkerTaskActionResponse,
|
||||
TagRecord,
|
||||
TaxonomyPayload,
|
||||
UpdateCommentPayload,
|
||||
@@ -243,7 +236,7 @@ export const adminApi = {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
testSubscription: (id: number) =>
|
||||
request<{ queued: boolean; id: number; delivery_id: number; job_id?: number | null }>(`/api/admin/subscriptions/${id}/test`, {
|
||||
request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
listSubscriptionDeliveries: async (limit = 80) =>
|
||||
@@ -255,42 +248,6 @@ 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'),
|
||||
@@ -448,22 +405,6 @@ 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',
|
||||
@@ -492,14 +433,6 @@ 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',
|
||||
|
||||
@@ -11,13 +11,6 @@ export interface CompressionResult {
|
||||
preview: CompressionPreview | null
|
||||
}
|
||||
|
||||
export type MediaUploadTargetFormat = 'auto' | 'avif' | 'webp'
|
||||
|
||||
interface ProcessedVariant {
|
||||
file: File
|
||||
preview: CompressionPreview
|
||||
}
|
||||
|
||||
interface ProcessImageOptions {
|
||||
quality: number
|
||||
maxWidth: number
|
||||
@@ -90,427 +83,6 @@ 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
|
||||
@@ -589,29 +161,33 @@ 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, processOptions)
|
||||
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,
|
||||
})
|
||||
} catch {
|
||||
return { file, usedCompressed: false, preview: null }
|
||||
}
|
||||
|
||||
const preview = buildPreview(file.size, processed.size)
|
||||
const { savedRatio } = preview
|
||||
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,
|
||||
}
|
||||
|
||||
if (!forceProcessed && processed.size >= file.size) {
|
||||
return { file, usedCompressed: false, preview }
|
||||
@@ -625,80 +201,30 @@ async function maybeProcessImageWithPrompt(
|
||||
return { file: processed, usedCompressed: true, preview }
|
||||
}
|
||||
|
||||
let variants: ProcessedVariant[]
|
||||
try {
|
||||
variants = await buildProcessedVariants(file, processOptions)
|
||||
} catch {
|
||||
variants = [
|
||||
{
|
||||
file: processed,
|
||||
preview,
|
||||
},
|
||||
]
|
||||
}
|
||||
const deltaText =
|
||||
savedBytes >= 0
|
||||
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)`
|
||||
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)`
|
||||
|
||||
const selectableVariants = forceProcessed
|
||||
? variants
|
||||
: variants.filter((item) => item.file.size < file.size && item.preview.savedRatio >= minSavingsRatio)
|
||||
const intro = forceProcessed
|
||||
? `${contextLabel}: 已生成规范化版本。`
|
||||
: `${contextLabel}: 检测到可压缩空间。`
|
||||
|
||||
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 useProcessed = window.confirm(
|
||||
[
|
||||
intro,
|
||||
`原始: ${formatBytes(file.size)}`,
|
||||
`处理后: ${formatBytes(processed.size)}`,
|
||||
deltaText,
|
||||
'',
|
||||
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
|
||||
].join('\n'),
|
||||
)
|
||||
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: selectedVariant?.file ?? file,
|
||||
file: useProcessed ? processed : file,
|
||||
usedCompressed: useProcessed,
|
||||
preview: selectedVariant?.preview ?? preview,
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,7 +235,6 @@ export async function maybeCompressImageWithPrompt(
|
||||
ask?: boolean
|
||||
minSavingsRatio?: number
|
||||
contextLabel?: string
|
||||
preferredFormats?: string[]
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
return maybeProcessImageWithPrompt(file, options)
|
||||
@@ -723,14 +248,13 @@ 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: options?.preferredFormats ?? ['image/avif', 'image/webp', 'image/jpeg'],
|
||||
preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'],
|
||||
coverWidth: Math.max(options?.width ?? 1600, 640),
|
||||
coverHeight: Math.max(options?.height ?? 900, 360),
|
||||
forceProcessed: true,
|
||||
@@ -738,42 +262,6 @@ 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 ''
|
||||
|
||||
@@ -61,38 +61,22 @@ export function savePolishWindowResult(
|
||||
return payload
|
||||
}
|
||||
|
||||
function parsePolishWindowResult(raw: string | null) {
|
||||
export function consumePolishWindowResult(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -125,79 +125,6 @@ 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
|
||||
@@ -349,8 +276,6 @@ 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[]
|
||||
}
|
||||
@@ -374,9 +299,6 @@ 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
|
||||
@@ -412,10 +334,8 @@ 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
|
||||
@@ -455,9 +375,6 @@ export interface SiteSettingsPayload {
|
||||
location?: string | null
|
||||
techStack?: string[]
|
||||
musicPlaylist?: MusicTrack[]
|
||||
musicEnabled?: boolean
|
||||
maintenanceModeEnabled?: boolean
|
||||
maintenanceAccessCode?: string | null
|
||||
aiEnabled?: boolean
|
||||
paragraphCommentsEnabled?: boolean
|
||||
commentVerificationMode?: HumanVerificationMode | null
|
||||
@@ -490,10 +407,8 @@ 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
|
||||
@@ -618,28 +533,6 @@ 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
|
||||
@@ -768,27 +661,6 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search, Sparkles } from 'lucide-react'
|
||||
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -80,31 +80,6 @@ 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)
|
||||
@@ -222,11 +197,6 @@ 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">
|
||||
@@ -271,94 +241,6 @@ 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 Search、Perplexity、Copilot/Bing、Gemini、Claude
|
||||
等 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>
|
||||
@@ -609,7 +491,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">{formatReferrerLabel(item.referrer)}</span>
|
||||
<span className="line-clamp-1 font-medium">{item.referrer}</span>
|
||||
<Badge variant="outline">{item.count}</Badge>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -219,7 +218,6 @@ export function CategoriesPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
data-testid="categories-search"
|
||||
placeholder="按分类名 / slug / 描述搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
@@ -231,7 +229,6 @@ export function CategoriesPage() {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`category-item-${item.slug}`}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setForm(toFormState(item))
|
||||
@@ -289,7 +286,6 @@ 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="输入分类名称"
|
||||
@@ -297,32 +293,19 @@ 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="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<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 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>
|
||||
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -394,7 +377,7 @@ export function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void handleSave()} disabled={saving} data-testid="category-save">
|
||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
|
||||
</Button>
|
||||
@@ -405,7 +388,6 @@ 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" />
|
||||
|
||||
@@ -898,7 +898,6 @@ export function CommentsPage() {
|
||||
setManualMatcherValue('')
|
||||
}}
|
||||
disabled={!manualMatcherValue.trim()}
|
||||
data-testid="comment-blacklist-add"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
新增
|
||||
@@ -909,7 +908,6 @@ 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">
|
||||
@@ -931,7 +929,6 @@ export function CommentsPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actingBlacklistId === item.id}
|
||||
data-testid={`blacklist-toggle-${item.id}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActingBlacklistId(item.id)
|
||||
@@ -962,7 +959,6 @@ export function CommentsPage() {
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={actingBlacklistId === item.id}
|
||||
data-testid={`blacklist-delete-${item.id}`}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('确定删除这条黑名单规则吗?')) {
|
||||
return
|
||||
|
||||
@@ -6,13 +6,10 @@ import {
|
||||
MessageSquareWarning,
|
||||
RefreshCcw,
|
||||
Rss,
|
||||
Sparkles,
|
||||
Star,
|
||||
Tags,
|
||||
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'
|
||||
@@ -38,7 +35,7 @@ import {
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
} from '@/lib/admin-format'
|
||||
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
||||
import type { AdminDashboardResponse } from '@/lib/types'
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
@@ -67,35 +64,8 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
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 [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
||||
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
@@ -105,15 +75,9 @@ export function DashboardPage() {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
|
||||
adminApi.dashboard(),
|
||||
adminApi.getWorkersOverview(),
|
||||
adminApi.analytics(),
|
||||
])
|
||||
const next = await adminApi.dashboard()
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
setWorkerOverview(nextWorkerOverview)
|
||||
setAnalytics(nextAnalytics)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
@@ -134,7 +98,7 @@ export function DashboardPage() {
|
||||
void loadDashboard(false)
|
||||
}, [loadDashboard])
|
||||
|
||||
if (loading || !data || !workerOverview || !analytics) {
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -142,10 +106,7 @@ export function DashboardPage() {
|
||||
<Skeleton key={index} className="h-44 rounded-3xl" />
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
<Skeleton className="h-[420px] rounded-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -185,19 +146,7 @@ export function DashboardPage() {
|
||||
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">
|
||||
@@ -365,142 +314,6 @@ export function DashboardPage() {
|
||||
{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 Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -379,25 +378,13 @@ export function FriendLinksPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<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 label="头像 URL">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="分类">
|
||||
<Input
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
CheckSquare,
|
||||
Copy,
|
||||
Download,
|
||||
Image as ImageIcon,
|
||||
RefreshCcw,
|
||||
Replace,
|
||||
@@ -11,7 +10,6 @@ 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'
|
||||
@@ -23,8 +21,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
prepareImageForUpload,
|
||||
type MediaUploadTargetFormat,
|
||||
maybeCompressImageWithPrompt,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -60,42 +58,6 @@ 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
|
||||
@@ -105,7 +67,7 @@ function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFor
|
||||
title: item.title ?? '',
|
||||
altText: item.alt_text ?? '',
|
||||
caption: item.caption ?? '',
|
||||
tags: normalizeMediaTags(item.tags).join(', '),
|
||||
tags: item.tags.join(', '),
|
||||
notes: item.notes ?? '',
|
||||
}
|
||||
}
|
||||
@@ -141,13 +103,6 @@ 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 {
|
||||
@@ -156,9 +111,8 @@ 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(normalizedItems)
|
||||
setItems(result.items)
|
||||
setProvider(result.provider)
|
||||
setBucket(result.bucket)
|
||||
})
|
||||
@@ -220,18 +174,22 @@ 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 mode =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' ? 'cover' : 'image'
|
||||
const normalizeCover =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
|
||||
|
||||
const result: File[] = []
|
||||
for (const file of files) {
|
||||
const compressed = await prepareImageForUpload(file, {
|
||||
compress: true,
|
||||
quality: safeQuality,
|
||||
targetFormat: uploadTargetFormat,
|
||||
contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}(${file.name})`,
|
||||
mode,
|
||||
})
|
||||
const compressed = normalizeCover
|
||||
? await normalizeCoverImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `封面规范化上传(${file.name})`,
|
||||
})
|
||||
: await maybeCompressImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `媒体库上传(${file.name})`,
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
|
||||
}
|
||||
@@ -261,7 +219,6 @@ export function MediaPage() {
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!selectedKeys.length || batchDeleting}
|
||||
data-testid="media-batch-delete"
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
|
||||
return
|
||||
@@ -302,36 +259,22 @@ 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_180px_96px_auto]">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||||
<Input
|
||||
data-testid="media-upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
@@ -348,15 +291,6 @@ 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}
|
||||
@@ -366,7 +300,6 @@ export function MediaPage() {
|
||||
/>
|
||||
<Button
|
||||
disabled={!uploadFiles.length || uploading}
|
||||
data-testid="media-upload"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setUploading(true)
|
||||
@@ -392,169 +325,10 @@ export function MediaPage() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {uploadFiles.length} 个文件。
|
||||
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
|
||||
? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。'
|
||||
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
|
||||
: ''}
|
||||
</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>
|
||||
|
||||
@@ -625,7 +399,6 @@ 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
|
||||
@@ -650,7 +423,7 @@ export function MediaPage() {
|
||||
title: result.title,
|
||||
alt_text: result.alt_text,
|
||||
caption: result.caption,
|
||||
tags: normalizeMediaTags(result.tags),
|
||||
tags: result.tags,
|
||||
notes: result.notes,
|
||||
}
|
||||
: item,
|
||||
@@ -700,12 +473,10 @@ 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">
|
||||
@@ -733,9 +504,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}
|
||||
{itemTags.length ? (
|
||||
{item.tags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{itemTags.slice(0, 4).map((tag) => (
|
||||
{item.tags.slice(0, 4).map((tag) => (
|
||||
<Badge key={`${item.key}-${tag}`} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
@@ -744,12 +515,7 @@ export function MediaPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveKey(item.key)}
|
||||
data-testid={`media-edit-${index}`}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
|
||||
元数据
|
||||
</Button>
|
||||
<Button
|
||||
@@ -775,7 +541,6 @@ export function MediaPage() {
|
||||
</Button>
|
||||
<input
|
||||
id={replaceInputId}
|
||||
data-testid={`media-replace-input-${index}`}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -818,7 +583,6 @@ 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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -18,6 +17,15 @@ 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
|
||||
@@ -27,8 +35,7 @@ function getDraftKey() {
|
||||
}
|
||||
|
||||
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
const { slug: routeSlug } = useParams<{ slug?: string }>()
|
||||
const slug = slugOverride ?? routeSlug ?? ''
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const [state, setState] = useState<CompareState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -42,28 +49,6 @@ 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),
|
||||
@@ -78,8 +63,8 @@ export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
title: post.title ?? slug,
|
||||
slug,
|
||||
path: markdown.path,
|
||||
savedMarkdown: markdown.markdown,
|
||||
draftMarkdown: markdown.markdown,
|
||||
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
|
||||
draftMarkdown: draft?.markdown ?? markdown.markdown,
|
||||
})
|
||||
})
|
||||
} catch (loadError) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -18,6 +17,15 @@ 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
|
||||
@@ -27,8 +35,7 @@ function getDraftKey() {
|
||||
}
|
||||
|
||||
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
const { slug: routeSlug } = useParams<{ slug?: string }>()
|
||||
const slug = slugOverride ?? routeSlug ?? ''
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const [state, setState] = useState<PreviewState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -43,7 +50,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
|
||||
if (draft && (!slug || draft.slug === slug)) {
|
||||
if (draft && draft.slug === slug) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
@@ -59,10 +66,6 @@ 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),
|
||||
|
||||
@@ -4,16 +4,15 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FilePlus2,
|
||||
FileUp,
|
||||
FolderOpen,
|
||||
GitCompareArrows,
|
||||
PencilLine,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -23,7 +22,6 @@ 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,
|
||||
@@ -49,16 +47,13 @@ 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 {
|
||||
@@ -211,14 +206,6 @@ 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) {
|
||||
@@ -255,17 +242,6 @@ 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')
|
||||
@@ -815,6 +791,8 @@ 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)
|
||||
@@ -828,8 +806,8 @@ export function PostsPage() {
|
||||
useState(false)
|
||||
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
|
||||
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
|
||||
const [localizingEditorImages, setLocalizingEditorImages] = useState(false)
|
||||
const [localizingCreateImages, setLocalizingCreateImages] = useState(false)
|
||||
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
|
||||
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
|
||||
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
|
||||
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
|
||||
const [editor, setEditor] = useState<PostFormState | null>(null)
|
||||
@@ -850,8 +828,6 @@ 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) {
|
||||
@@ -954,7 +930,6 @@ export function PostsPage() {
|
||||
useEffect(() => {
|
||||
setEditorMode('workspace')
|
||||
setEditorPanels(defaultWorkbenchPanels)
|
||||
editorPolishDraftKeyRef.current = null
|
||||
|
||||
if (!slug) {
|
||||
setEditor(null)
|
||||
@@ -967,12 +942,6 @@ export function PostsPage() {
|
||||
void loadEditor(slug)
|
||||
}, [loadEditor, slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createDialogOpen) {
|
||||
createPolishDraftKeyRef.current = null
|
||||
}
|
||||
}, [createDialogOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadataDialog && !slug && !createDialogOpen) {
|
||||
return
|
||||
@@ -1055,175 +1024,6 @@ 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 {
|
||||
@@ -1462,143 +1262,67 @@ export function PostsPage() {
|
||||
}
|
||||
}, [createForm])
|
||||
|
||||
const localizeEditorMarkdownImages = useCallback(async () => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMarkdown = buildDraftMarkdownForWindow(editor)
|
||||
if (!stripFrontmatter(sourceMarkdown).trim()) {
|
||||
toast.error('先准备一点正文,再执行正文图片本地化。')
|
||||
return
|
||||
}
|
||||
|
||||
const uploadEditorCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setLocalizingEditorImages(true)
|
||||
const result = await adminApi.localizePostMarkdownImages({
|
||||
markdown: sourceMarkdown,
|
||||
prefix: buildInlineImagePrefix(editor.slug),
|
||||
setUploadingEditorCover(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.message('正文里没有检测到需要本地化的远程图片。')
|
||||
return
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setEditor((current) =>
|
||||
current ? applyPolishedEditorState(current, result.markdown) : current,
|
||||
)
|
||||
setEditor((current) => (current ? { ...current, image: url } : current))
|
||||
})
|
||||
|
||||
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} 个链接。`)
|
||||
}
|
||||
toast.success('封面已上传并回填。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
} finally {
|
||||
setLocalizingEditorImages(false)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const localizeCreateMarkdownImages = useCallback(async () => {
|
||||
const sourceMarkdown = buildCreateMarkdownForWindow(createForm)
|
||||
if (!stripFrontmatter(sourceMarkdown).trim()) {
|
||||
toast.error('先准备一点正文,再执行正文图片本地化。')
|
||||
return
|
||||
setUploadingEditorCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const uploadCreateCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setLocalizingCreateImages(true)
|
||||
const result = await adminApi.localizePostMarkdownImages({
|
||||
markdown: sourceMarkdown,
|
||||
prefix: buildInlineImagePrefix(createForm.slug || createForm.title),
|
||||
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.message('正文里没有检测到需要本地化的远程图片。')
|
||||
return
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
|
||||
setCreateForm((current) => ({ ...current, image: url }))
|
||||
})
|
||||
|
||||
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} 个链接。`)
|
||||
}
|
||||
toast.success('封面已上传并回填。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
} finally {
|
||||
setLocalizingCreateImages(false)
|
||||
setUploadingCreateCover(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(
|
||||
() =>
|
||||
@@ -2114,6 +1838,32 @@ 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">
|
||||
@@ -2127,7 +1877,7 @@ export function PostsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={openCreateDialog} data-testid="posts-open-create">
|
||||
<Button variant="outline" onClick={openCreateDialog}>
|
||||
<FilePlus2 className="h-4 w-4" />
|
||||
新建草稿
|
||||
</Button>
|
||||
@@ -2169,7 +1919,6 @@ 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}
|
||||
@@ -2241,7 +1990,6 @@ 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',
|
||||
@@ -2351,7 +2099,7 @@ export function PostsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={closeEditorDialog} data-testid="post-editor-close">
|
||||
<Button variant="outline" onClick={closeEditorDialog}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回文章列表
|
||||
</Button>
|
||||
@@ -2400,7 +2148,6 @@ export function PostsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
data-testid="post-editor-title"
|
||||
value={editor.title}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
@@ -2527,34 +2274,29 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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 label="封面图 URL">
|
||||
<Input
|
||||
value={editor.image}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, image: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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}
|
||||
disabled={generatingEditorCover || uploadingEditorCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingEditorCover
|
||||
@@ -2697,26 +2439,6 @@ 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={() => {
|
||||
@@ -2752,12 +2474,11 @@ export function PostsPage() {
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
恢复
|
||||
</Button>
|
||||
<Button onClick={() => void saveEditor()} disabled={saving} data-testid="post-editor-save">
|
||||
<Button onClick={() => void saveEditor()} disabled={saving}>
|
||||
<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}”吗?`)) {
|
||||
@@ -2893,7 +2614,6 @@ 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 }))
|
||||
@@ -2902,7 +2622,6 @@ 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 }))
|
||||
@@ -3008,32 +2727,27 @@ export function PostsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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 label="封面图 URL">
|
||||
<Input
|
||||
value={createForm.image}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, image: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</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}
|
||||
disabled={generatingCreateCover || uploadingCreateCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingCreateCover
|
||||
@@ -3157,26 +2871,6 @@ 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={() => {
|
||||
@@ -3213,7 +2907,6 @@ export function PostsPage() {
|
||||
恢复模板
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="post-create-submit"
|
||||
onClick={async () => {
|
||||
if (!createForm.title.trim()) {
|
||||
toast.error('创建文章时必须填写标题。')
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, 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'
|
||||
@@ -19,6 +18,10 @@ import {
|
||||
formatReviewType,
|
||||
reviewTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||
|
||||
type ReviewFormState = {
|
||||
@@ -100,12 +103,14 @@ 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 {
|
||||
@@ -212,6 +217,29 @@ 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">
|
||||
@@ -277,7 +305,6 @@ export function ReviewsPage() {
|
||||
<button
|
||||
key={review.id}
|
||||
type="button"
|
||||
data-testid={`review-item-${review.id}`}
|
||||
onClick={() => {
|
||||
setSelectedId(review.id)
|
||||
setForm(toFormState(review))
|
||||
@@ -336,7 +363,6 @@ 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('标题不能为空。')
|
||||
@@ -385,7 +411,6 @@ export function ReviewsPage() {
|
||||
{selectedReview ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
data-testid="review-delete"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('确定删除这条评测吗?')) {
|
||||
@@ -428,7 +453,6 @@ 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 }))
|
||||
@@ -463,7 +487,6 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
<FormField label="评测日期">
|
||||
<Input
|
||||
data-testid="review-date"
|
||||
type="date"
|
||||
value={form.reviewDate}
|
||||
onChange={(event) =>
|
||||
@@ -485,21 +508,36 @@ export function ReviewsPage() {
|
||||
</FormField>
|
||||
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
|
||||
<div className="space-y-3">
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{form.cover ? (
|
||||
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
|
||||
@@ -545,7 +583,6 @@ export function ReviewsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="review-ai-polish"
|
||||
onClick={() => void requestDescriptionPolish()}
|
||||
disabled={polishingDescription}
|
||||
>
|
||||
@@ -566,7 +603,6 @@ export function ReviewsPage() {
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
data-testid="review-description"
|
||||
value={form.description}
|
||||
onChange={(event) => {
|
||||
const nextDescription = event.target.value
|
||||
@@ -589,7 +625,6 @@ export function ReviewsPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="review-ai-adopt"
|
||||
onClick={() => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -248,7 +248,6 @@ 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"
|
||||
@@ -305,12 +304,7 @@ 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)}
|
||||
data-testid={`revision-open-${item.id}`}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
|
||||
<History className="h-4 w-4" />
|
||||
查看 / 对比
|
||||
</Button>
|
||||
@@ -377,7 +371,6 @@ 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" />
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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'
|
||||
@@ -133,9 +132,6 @@ function normalizeSettingsResponse(
|
||||
web_push_vapid_public_key: input.web_push_vapid_public_key ?? null,
|
||||
web_push_vapid_private_key: input.web_push_vapid_private_key ?? null,
|
||||
web_push_vapid_subject: input.web_push_vapid_subject ?? null,
|
||||
music_enabled: input.music_enabled ?? true,
|
||||
maintenance_mode_enabled: input.maintenance_mode_enabled ?? false,
|
||||
maintenance_access_code: input.maintenance_access_code ?? null,
|
||||
ai_active_provider_id:
|
||||
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
||||
}
|
||||
@@ -181,9 +177,6 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
location: form.location,
|
||||
techStack: form.tech_stack,
|
||||
musicPlaylist: form.music_playlist,
|
||||
musicEnabled: form.music_enabled,
|
||||
maintenanceModeEnabled: form.maintenance_mode_enabled,
|
||||
maintenanceAccessCode: form.maintenance_access_code,
|
||||
aiEnabled: form.ai_enabled,
|
||||
paragraphCommentsEnabled: form.paragraph_comments_enabled,
|
||||
commentVerificationMode: form.comment_verification_mode,
|
||||
@@ -216,10 +209,8 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
|
||||
mediaR2AccessKeyId: form.media_r2_access_key_id,
|
||||
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
|
||||
seoFaviconUrl: form.seo_favicon_url,
|
||||
seoDefaultOgImage: form.seo_default_og_image,
|
||||
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
|
||||
seoWechatShareQrEnabled: form.seo_wechat_share_qr_enabled,
|
||||
notificationWebhookUrl: form.notification_webhook_url,
|
||||
notificationChannelType: form.notification_channel_type,
|
||||
notificationCommentEnabled: form.notification_comment_enabled,
|
||||
@@ -494,14 +485,13 @@ export function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadSettings(true)} data-testid="site-settings-refresh">
|
||||
<Button variant="outline" onClick={() => void loadSettings(true)}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={reindexing}
|
||||
data-testid="site-settings-reindex"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setReindexing(true)
|
||||
@@ -520,13 +510,7 @@ export function SiteSettingsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
data-testid="site-settings-save"
|
||||
onClick={async () => {
|
||||
if (form.maintenance_mode_enabled && !form.maintenance_access_code?.trim()) {
|
||||
toast.error('开启维护模式前请先填写访问口令。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const updated = await adminApi.updateSiteSettings(toPayload(form))
|
||||
@@ -559,7 +543,6 @@ export function SiteSettingsPage() {
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="站点名称">
|
||||
<Input
|
||||
data-testid="site-settings-site-name"
|
||||
value={form.site_name ?? ''}
|
||||
onChange={(event) => updateField('site_name', event.target.value)}
|
||||
/>
|
||||
@@ -620,21 +603,11 @@ export function SiteSettingsPage() {
|
||||
onChange={(event) => updateField('owner_name', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(ownerAvatarUrl) => updateField('owner_avatar_url', ownerAvatarUrl)}
|
||||
prefix="site-assets/"
|
||||
contextLabel="站长头像上传"
|
||||
remoteTitle={form.owner_name || form.site_name || '站长头像'}
|
||||
dataTestIdPrefix="site-owner-avatar"
|
||||
/>
|
||||
</div>
|
||||
<Field label="头像 URL">
|
||||
<Input
|
||||
value={form.owner_avatar_url ?? ''}
|
||||
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="站长简介">
|
||||
@@ -751,7 +724,6 @@ export function SiteSettingsPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
|
||||
<Input
|
||||
data-testid="site-settings-popup-title"
|
||||
value={form.subscription_popup_title}
|
||||
onChange={(event) =>
|
||||
updateField('subscription_popup_title', event.target.value)
|
||||
@@ -788,55 +760,6 @@ export function SiteSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>维护模式</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,前台访问者需要先输入口令才能看到内容,适合开发联调、灰度预览或上线前封站检查。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.maintenance_mode_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('maintenance_mode_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">开启前台维护模式</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
开启后,访问首页、文章页、分类页等前台内容都会先进入维护页;只有输入正确口令后才会放行。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field
|
||||
label="访问口令"
|
||||
hint="建议设置成临时口令后发给测试同事;修改口令后,旧口令拿到的访问凭证会自动失效。"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.maintenance_access_code ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('maintenance_access_code', event.target.value)
|
||||
}
|
||||
placeholder="例如:staging-2026"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Badge variant={form.maintenance_mode_enabled ? 'warning' : 'outline'}>
|
||||
{form.maintenance_mode_enabled ? '维护中' : '正常开放'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>运行时安全 / 推送配置</CardTitle>
|
||||
@@ -916,39 +839,11 @@ export function SiteSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="Favicon URL" hint="浏览器标签页图标;支持外链,也支持上传 / 抓取 / 选择媒体库。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.seo_favicon_url ?? ''}
|
||||
onChange={(event) => updateField('seo_favicon_url', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.seo_favicon_url ?? ''}
|
||||
onChange={(seoFaviconUrl) => updateField('seo_favicon_url', seoFaviconUrl)}
|
||||
prefix="seo-assets/"
|
||||
contextLabel="站点 favicon 上传"
|
||||
remoteTitle={form.site_name || form.site_title || '站点 favicon'}
|
||||
dataTestIdPrefix="site-favicon"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退,也支持上传 / 抓取 / 选择媒体库。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(seoDefaultOgImage) =>
|
||||
updateField('seo_default_og_image', seoDefaultOgImage)
|
||||
}
|
||||
prefix="seo-assets/"
|
||||
contextLabel="默认 OG 图上传"
|
||||
remoteTitle={form.site_name || form.site_title || '默认 OG 图'}
|
||||
dataTestIdPrefix="site-default-og"
|
||||
/>
|
||||
</div>
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Twitter / X Handle" hint="例如 @initcool。">
|
||||
<Input
|
||||
@@ -958,24 +853,6 @@ export function SiteSettingsPage() {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.seo_wechat_share_qr_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('seo_wechat_share_qr_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">开启文章页微信扫码分享</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
开启后,文章摘要卡片会出现本地生成的微信二维码弹层,方便移动端扫码打开规范链接。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 lg:col-span-2 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<Field label="通知渠道" hint="可选 Webhook 或 ntfy。">
|
||||
<Select
|
||||
@@ -1228,7 +1105,6 @@ export function SiteSettingsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="site-settings-test-provider"
|
||||
disabled={testingProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -1367,7 +1243,6 @@ export function SiteSettingsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="site-settings-test-image-provider"
|
||||
disabled={testingImageProvider}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -1521,7 +1396,6 @@ export function SiteSettingsPage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="site-settings-test-storage"
|
||||
disabled={testingR2Storage}
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -1665,33 +1539,13 @@ export function SiteSettingsPage() {
|
||||
<div>
|
||||
<CardTitle>音乐侧栏</CardTitle>
|
||||
<CardDescription>
|
||||
可以直接控制前台是否显示音乐播放器,歌单配置会继续保留在后台。
|
||||
把头部播放器的曲目清单和单曲属性放到独立侧边栏里维护。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={form.music_enabled ? 'default' : 'outline'}>
|
||||
{form.music_enabled ? '前台已开启' : '前台已关闭'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
</div>
|
||||
<Badge variant="outline">{form.music_playlist.length} 首</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.music_enabled}
|
||||
onChange={(event) => updateField('music_enabled', event.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">前台显示音乐播放器</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
关闭后前台头部和移动菜单里的音乐模块会整体隐藏,但下面维护的歌单内容不会丢失。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
{form.music_playlist.map((track, index) => {
|
||||
const active = index === selectedTrackIndex
|
||||
@@ -1807,25 +1661,13 @@ export function SiteSettingsPage() {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
<MediaUrlControls
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(coverImageUrl) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', coverImageUrl)
|
||||
}
|
||||
prefix="music-covers/"
|
||||
contextLabel="音乐封面上传"
|
||||
remoteTitle={selectedTrack.title || `曲目 ${selectedTrackIndex + 1} 封面`}
|
||||
dataTestIdPrefix="site-music-cover"
|
||||
/>
|
||||
</div>
|
||||
<Field label="封面图 URL">
|
||||
<Input
|
||||
value={selectedTrack.cover_image_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -19,11 +18,8 @@ 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, WorkerJobRecord } from '@/lib/types'
|
||||
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger'
|
||||
import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
@@ -75,127 +71,6 @@ 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[]>([])
|
||||
@@ -205,29 +80,20 @@ 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, nextWorkerJobs] = await Promise.all([
|
||||
const [nextSubscriptions, nextDeliveries] = 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('订阅中心已刷新。')
|
||||
@@ -257,79 +123,6 @@ 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())
|
||||
@@ -365,132 +158,6 @@ 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">
|
||||
@@ -521,13 +188,11 @@ export function SubscriptionsPage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={digesting !== null}
|
||||
data-testid="subscriptions-send-weekly"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('weekly')
|
||||
const result = await adminApi.runDigestWorker('weekly')
|
||||
setLastActionJobId(result.job.id)
|
||||
toast.success(`周报任务已入队:#${result.job.id}`)
|
||||
const result = await adminApi.sendSubscriptionDigest('weekly')
|
||||
toast.success(`周报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
|
||||
@@ -541,13 +206,11 @@ export function SubscriptionsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
disabled={digesting !== null}
|
||||
data-testid="subscriptions-send-monthly"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('monthly')
|
||||
const result = await adminApi.runDigestWorker('monthly')
|
||||
setLastActionJobId(result.job.id)
|
||||
toast.success(`月报任务已入队:#${result.job.id}`)
|
||||
const result = await adminApi.sendSubscriptionDigest('monthly')
|
||||
toast.success(`月报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
|
||||
@@ -559,11 +222,6 @@ 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>
|
||||
|
||||
@@ -656,12 +314,7 @@ export function SubscriptionsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={submitting}
|
||||
onClick={() => void submitForm()}
|
||||
data-testid="subscriptions-save"
|
||||
>
|
||||
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
|
||||
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
|
||||
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
|
||||
</Button>
|
||||
@@ -679,91 +332,121 @@ export function SubscriptionsPage() {
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>当前订阅目标</CardTitle>
|
||||
<CardDescription>按浏览器提醒 / 邮件订阅 / 其他渠道分组查看,并支持搜索、筛选、测试与编辑。</CardDescription>
|
||||
<CardDescription>支持单条测试、编辑 filters / metadata,以及删除。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{filteredSubscriptions.length} / {subscriptions.length} 个
|
||||
</Badge>
|
||||
<Badge variant="outline">{subscriptions.length} 个</Badge>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -785,14 +468,11 @@ export function SubscriptionsPage() {
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>重试</TableHead>
|
||||
<TableHead>Worker</TableHead>
|
||||
<TableHead>响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deliveries.map((item) => {
|
||||
const workerJob = deliveryJobMap.get(item.id)
|
||||
return (
|
||||
{deliveries.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
|
||||
<TableCell>
|
||||
@@ -814,26 +494,11 @@ 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>
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -219,7 +218,6 @@ export function TagsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
data-testid="tags-search"
|
||||
placeholder="按标签名 / slug / 描述搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
@@ -231,7 +229,6 @@ export function TagsPage() {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`tag-item-${item.slug}`}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setForm(toFormState(item))
|
||||
@@ -289,7 +286,6 @@ 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="输入标签名称"
|
||||
@@ -297,32 +293,19 @@ 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="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
|
||||
<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 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>
|
||||
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -394,7 +377,7 @@ export function TagsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void handleSave()} disabled={saving} data-testid="tag-save">
|
||||
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
|
||||
</Button>
|
||||
@@ -405,7 +388,6 @@ 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" />
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
import {
|
||||
LoaderCircle,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Send,
|
||||
SquareTerminal,
|
||||
StopCircle,
|
||||
TimerReset,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { WorkerJobRecord, WorkerOverview } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
function prettyJson(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
return 'success' as const
|
||||
case 'running':
|
||||
return 'default' as const
|
||||
case 'queued':
|
||||
return 'secondary' as const
|
||||
case 'failed':
|
||||
return 'danger' as const
|
||||
case 'cancelled':
|
||||
return 'warning' as const
|
||||
default:
|
||||
return 'outline' as const
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_OVERVIEW: WorkerOverview = {
|
||||
total_jobs: 0,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
active_jobs: 0,
|
||||
worker_stats: [],
|
||||
catalog: [],
|
||||
}
|
||||
|
||||
export function WorkersPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [overview, setOverview] = useState<WorkerOverview>(EMPTY_OVERVIEW)
|
||||
const [jobs, setJobs] = useState<WorkerJobRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all')
|
||||
const [kindFilter, setKindFilter] = useState(searchParams.get('kind') || 'all')
|
||||
const [workerFilter, setWorkerFilter] = useState(searchParams.get('worker') || 'all')
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '')
|
||||
|
||||
const requestedJobId = useMemo(() => {
|
||||
const raw = Number.parseInt(searchParams.get('job') || '', 10)
|
||||
return Number.isFinite(raw) ? raw : null
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
setStatusFilter(searchParams.get('status') || 'all')
|
||||
setKindFilter(searchParams.get('kind') || 'all')
|
||||
setWorkerFilter(searchParams.get('worker') || 'all')
|
||||
setSearch(searchParams.get('search') || '')
|
||||
}, [searchParams])
|
||||
|
||||
const loadData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const [nextOverview, nextJobs] = await Promise.all([
|
||||
adminApi.getWorkersOverview(),
|
||||
adminApi.listWorkerJobs({
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
jobKind: kindFilter === 'all' ? undefined : kindFilter,
|
||||
workerName: workerFilter === 'all' ? undefined : workerFilter,
|
||||
search: search.trim() || undefined,
|
||||
limit: 120,
|
||||
}),
|
||||
])
|
||||
let nextJobList = nextJobs.jobs
|
||||
if (requestedJobId && !nextJobList.some((item) => item.id === requestedJobId)) {
|
||||
try {
|
||||
const requestedJob = await adminApi.getWorkerJob(requestedJobId)
|
||||
nextJobList = [requestedJob, ...nextJobList]
|
||||
} catch {
|
||||
// ignore deep-link miss and keep current list
|
||||
}
|
||||
}
|
||||
startTransition(() => {
|
||||
setOverview(nextOverview)
|
||||
setJobs(nextJobList)
|
||||
setTotal(nextJobs.total)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('Worker 管理面板已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载 worker 面板。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [kindFilter, requestedJobId, search, statusFilter, workerFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(false)
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadData(false)
|
||||
}, 5000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedJobId((current) => {
|
||||
if (requestedJobId) {
|
||||
return requestedJobId
|
||||
}
|
||||
if (current && jobs.some((item) => item.id === current)) {
|
||||
return current
|
||||
}
|
||||
return jobs[0]?.id ?? null
|
||||
})
|
||||
}, [jobs, requestedJobId])
|
||||
|
||||
const selectedJob = useMemo(
|
||||
() => jobs.find((item) => item.id === selectedJobId) ?? null,
|
||||
[jobs, selectedJobId],
|
||||
)
|
||||
|
||||
const runTask = useCallback(async (task: 'weekly' | 'monthly' | 'retry') => {
|
||||
try {
|
||||
setActioning(task)
|
||||
const result =
|
||||
task === 'retry'
|
||||
? await adminApi.runRetryDeliveriesWorker(80)
|
||||
: await adminApi.runDigestWorker(task)
|
||||
toast.success(`已入队:#${result.job.id} ${result.job.display_name ?? result.job.worker_name}`)
|
||||
await loadData(false)
|
||||
setSelectedJobId(result.job.id)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '任务入队失败。')
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}, [loadData])
|
||||
|
||||
const workerOptions = overview.catalog.map((item) => ({
|
||||
value: item.worker_name,
|
||||
label: item.label,
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-40 rounded-3xl" />
|
||||
<Skeleton className="h-[760px] rounded-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">Workers / Queue</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">异步 Worker 控制台</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
统一查看后台下载、通知投递与 digest / 重试任务;支持筛选、查看详情、取消、重跑与手动触发。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="workers-run-weekly"
|
||||
disabled={actioning !== null}
|
||||
onClick={() => void runTask('weekly')}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{actioning === 'weekly' ? '入队中...' : '发送周报'}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="workers-run-monthly"
|
||||
disabled={actioning !== null}
|
||||
onClick={() => void runTask('monthly')}
|
||||
>
|
||||
<Workflow className="h-4 w-4" />
|
||||
{actioning === 'monthly' ? '入队中...' : '发送月报'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="workers-run-retry"
|
||||
disabled={actioning !== null}
|
||||
onClick={() => void runTask('retry')}
|
||||
>
|
||||
<TimerReset className="h-4 w-4" />
|
||||
{actioning === 'retry' ? '处理中...' : '重试待投递'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
{[
|
||||
{ label: '总任务', value: overview.total_jobs, hint: `${overview.worker_stats.length} 种 worker`, icon: SquareTerminal },
|
||||
{ label: '排队中', value: overview.queued, hint: 'queued', icon: LoaderCircle },
|
||||
{ label: '运行中', value: overview.running, hint: 'running', icon: Workflow },
|
||||
{ label: '成功', value: overview.succeeded, hint: 'succeeded', icon: Send },
|
||||
{ label: '失败', value: overview.failed, hint: 'failed', icon: RotateCcw },
|
||||
{ label: '已取消', value: overview.cancelled, hint: 'cancelled', icon: StopCircle },
|
||||
].map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Card key={item.label}>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-5">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight">{item.value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.hint}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Worker 分类视图</CardTitle>
|
||||
<CardDescription>快速看每类 worker / task 当前堆积、失败与最近执行情况。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{overview.worker_stats.map((item) => (
|
||||
<button
|
||||
key={item.worker_name}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-3xl border border-border/70 bg-background/50 p-4 text-left transition hover:border-primary/30 hover:bg-primary/5',
|
||||
workerFilter === item.worker_name && 'border-primary/40 bg-primary/10',
|
||||
)}
|
||||
onClick={() => setWorkerFilter((current) => (current === item.worker_name ? 'all' : item.worker_name))}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{item.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{item.job_kind}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-5 gap-2 text-center text-xs">
|
||||
{[
|
||||
['Q', item.queued],
|
||||
['R', item.running],
|
||||
['OK', item.succeeded],
|
||||
['ERR', item.failed],
|
||||
['X', item.cancelled],
|
||||
].map(([label, value]) => (
|
||||
<div key={String(label)} className="rounded-2xl border border-border/70 px-2 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-base font-semibold text-foreground">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
最近任务:{item.last_job_at ?? '—'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.4fr_0.9fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务历史</CardTitle>
|
||||
<CardDescription>当前筛选后共 {total} 条,列表保留最近 120 条任务记录。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[180px_180px_220px_1fr]">
|
||||
<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="succeeded">succeeded</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</Select>
|
||||
<Select value={kindFilter} onChange={(event) => setKindFilter(event.target.value)}>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="worker">worker</option>
|
||||
<option value="task">task</option>
|
||||
</Select>
|
||||
<Select value={workerFilter} onChange={(event) => setWorkerFilter(event.target.value)}>
|
||||
<option value="all">全部 worker</option>
|
||||
{workerOptions.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="workers-search"
|
||||
placeholder="按 worker / display / entity 搜索"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>任务</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>实体</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
data-testid={`worker-job-row-${item.id}`}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
selectedJobId === item.id && 'bg-primary/5',
|
||||
)}
|
||||
onClick={() => setSelectedJobId(item.id)}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-foreground">{item.display_name ?? item.worker_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.worker_name}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
|
||||
{item.cancel_requested ? <div className="text-[11px] text-amber-600">cancel requested</div> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>{item.job_kind}</div>
|
||||
<div>{item.requested_by ?? 'system'}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{item.related_entity_type && item.related_entity_id
|
||||
? `${item.related_entity_type}:${item.related_entity_id}`
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<div>{item.queued_at ?? item.created_at}</div>
|
||||
<div>done: {item.finished_at ?? '—'}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!jobs.length ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
||||
当前筛选没有匹配任务。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务详情</CardTitle>
|
||||
<CardDescription>查看 payload / result / error,并对单个任务执行取消或重跑。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedJob ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">#{selectedJob.id}</Badge>
|
||||
<Badge variant={statusVariant(selectedJob.status)}>{selectedJob.status}</Badge>
|
||||
<Badge variant="secondary">{selectedJob.job_kind}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
{selectedJob.display_name ?? selectedJob.worker_name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{selectedJob.worker_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
['请求人', selectedJob.requested_by ?? 'system'],
|
||||
['来源', selectedJob.requested_source ?? 'system'],
|
||||
['关联实体', selectedJob.related_entity_type && selectedJob.related_entity_id ? `${selectedJob.related_entity_type}:${selectedJob.related_entity_id}` : '—'],
|
||||
['尝试次数', `${selectedJob.attempts_count} / ${selectedJob.max_attempts}`],
|
||||
['排队时间', selectedJob.queued_at ?? selectedJob.created_at],
|
||||
['开始时间', selectedJob.started_at ?? '—'],
|
||||
['完成时间', selectedJob.finished_at ?? '—'],
|
||||
['上游任务', selectedJob.parent_job_id ? `#${selectedJob.parent_job_id}` : '—'],
|
||||
].map(([label, value]) => (
|
||||
<div key={String(label)} className="rounded-2xl border border-border/70 bg-background/50 px-4 py-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{label}</div>
|
||||
<div className="mt-2 text-sm text-foreground break-all">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!selectedJob.can_cancel || actioning === `cancel-${selectedJob.id}`}
|
||||
data-testid="workers-cancel-job"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioning(`cancel-${selectedJob.id}`)
|
||||
const result = await adminApi.cancelWorkerJob(selectedJob.id)
|
||||
toast.success(`任务 #${result.id} 已标记取消。`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '取消任务失败。')
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
{actioning === `cancel-${selectedJob.id}` ? '取消中...' : '取消任务'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedJob.can_retry || actioning === `retry-${selectedJob.id}`}
|
||||
data-testid="workers-retry-job"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioning(`retry-${selectedJob.id}`)
|
||||
const result = await adminApi.retryWorkerJob(selectedJob.id)
|
||||
toast.success(`已重跑,新的任务 #${result.job.id}`)
|
||||
await loadData(false)
|
||||
setSelectedJobId(result.job.id)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '重跑任务失败。')
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{actioning === `retry-${selectedJob.id}` ? '重跑中...' : '重跑任务'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-foreground">Payload</div>
|
||||
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{prettyJson(selectedJob.payload)}</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-foreground">Result</div>
|
||||
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{prettyJson(selectedJob.result)}</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium text-foreground">Error</div>
|
||||
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{selectedJob.error_text ?? '—'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 bg-background/50 px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
暂无可查看的任务详情。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -6972,7 +6972,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"fastembed",
|
||||
"image",
|
||||
"include_dir",
|
||||
"insta",
|
||||
"loco-rs",
|
||||
@@ -6985,7 +6984,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
|
||||
@@ -43,11 +43,9 @@ 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"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM rust:1.94.1-trixie AS chef
|
||||
RUN rustup component add rustfmt clippy \
|
||||
&& cargo install cargo-chef --locked
|
||||
FROM rust:1.94-trixie AS chef
|
||||
RUN cargo install cargo-chef --locked
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
@@ -10,20 +9,11 @@ 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 --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
|
||||
RUN cargo chef cook --release --locked --recipe-path recipe.json
|
||||
|
||||
COPY . .
|
||||
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
|
||||
RUN cargo build --release --locked --bin termi_api-cli
|
||||
|
||||
FROM debian:trixie-slim AS runtime
|
||||
RUN apt-get update \
|
||||
@@ -31,7 +21,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /tmp/termi_api-cli /usr/local/bin/termi_api-cli
|
||||
COPY --from=builder /app/target/release/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
|
||||
|
||||
@@ -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: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。"
|
||||
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
|
||||
approved: true
|
||||
|
||||
- id: 5
|
||||
pid: 4
|
||||
author: "清嘉"
|
||||
email: "qingjia@example.com"
|
||||
content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。"
|
||||
content: "黄山记的序文很适合测试首屏摘要生成。"
|
||||
approved: true
|
||||
|
||||
- id: 6
|
||||
|
||||
@@ -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 @@
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
rating: 5
|
||||
review_date: "2024-02-18"
|
||||
status: "published"
|
||||
description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。"
|
||||
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。"
|
||||
tags: ["经济", "非虚构", "中国"]
|
||||
cover: "/review-covers/placed-within.svg"
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
site_name: "InitCool"
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://init.cool"
|
||||
site_title: "InitCool · 技术笔记与内容档案"
|
||||
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
|
||||
hero_title: "欢迎来到 InitCool"
|
||||
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
|
||||
site_title: "InitCool · 中文长文与 AI 搜索实验站"
|
||||
site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。"
|
||||
hero_title: "欢迎来到我的中文内容实验站"
|
||||
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
@@ -43,9 +43,6 @@
|
||||
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"
|
||||
@@ -60,4 +57,3 @@
|
||||
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
|
||||
ai_top_k: 4
|
||||
ai_chunk_size: 1200
|
||||
seo_favicon_url: null
|
||||
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
- 徐霞客
|
||||
- 游记
|
||||
- 太和山
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
---
|
||||
|
||||
# 徐霞客游记·游太和山日记(下)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 游黄山记(中)
|
||||
slug: loco-rs-framework
|
||||
description: 钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。
|
||||
description: 钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。
|
||||
category: 古籍游记
|
||||
post_type: article
|
||||
pinned: false
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
---
|
||||
|
||||
# 游黄山记(中)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 徐霞客游记·游恒山日记
|
||||
slug: rust-programming-tips
|
||||
description: 游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。
|
||||
description: 游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。
|
||||
category: 古籍游记
|
||||
post_type: article
|
||||
pinned: false
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
- 徐霞客
|
||||
- 恒山
|
||||
- 悬空寺
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
---
|
||||
|
||||
# 徐霞客游记·游恒山日记
|
||||
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
---
|
||||
|
||||
# 游黄山记(上)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 徐霞客游记·游太和山日记(上)
|
||||
slug: welcome-to-termi
|
||||
description: 《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。
|
||||
description: 《徐霞客游记》太和山上篇,适合作为中文长文测试样本。
|
||||
category: 古籍游记
|
||||
post_type: article
|
||||
pinned: true
|
||||
@@ -12,7 +12,7 @@ tags:
|
||||
- 徐霞客
|
||||
- 游记
|
||||
- 太和山
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
---
|
||||
|
||||
# 徐霞客游记·游太和山日记(上)
|
||||
|
||||
@@ -43,11 +43,6 @@ 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]
|
||||
@@ -95,11 +90,6 @@ 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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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::{BootResult, StartMode, create_app},
|
||||
boot::{create_app, BootResult, StartMode},
|
||||
config::Config,
|
||||
controller::AppRoutes,
|
||||
db::{self, truncate_table},
|
||||
environment::Environment,
|
||||
task::Tasks,
|
||||
Result,
|
||||
};
|
||||
use migration::Migrator;
|
||||
use sea_orm::{
|
||||
@@ -99,9 +99,7 @@ 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 {
|
||||
@@ -154,9 +152,7 @@ impl Hooks for App {
|
||||
}
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
queue
|
||||
.register(NotificationDeliveryWorker::build(ctx))
|
||||
.await?;
|
||||
queue.register(NotificationDeliveryWorker::build(ctx)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -338,7 +334,8 @@ 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"]
|
||||
@@ -346,7 +343,8 @@ 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()
|
||||
@@ -383,28 +381,6 @@ 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),
|
||||
@@ -446,10 +422,6 @@ 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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::http::{HeaderMap, header};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use loco_rs::prelude::*;
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
@@ -75,8 +75,7 @@ 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())
|
||||
@@ -193,7 +192,8 @@ 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) {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
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,
|
||||
@@ -26,10 +22,7 @@ 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, worker_jobs,
|
||||
},
|
||||
workers::downloader::{DownloadWorkerArgs, download_media_to_storage, normalize_target_format},
|
||||
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -175,9 +168,6 @@ 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,
|
||||
@@ -213,10 +203,8 @@ 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,
|
||||
@@ -358,38 +346,6 @@ 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>,
|
||||
@@ -503,37 +459,6 @@ 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,
|
||||
@@ -584,199 +509,6 @@ 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: ®ex::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: ®ex::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: ®ex::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>>> {
|
||||
@@ -1025,9 +757,6 @@ 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(),
|
||||
@@ -1069,10 +798,8 @@ 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
|
||||
@@ -1730,94 +1457,6 @@ 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,
|
||||
@@ -2190,89 +1829,6 @@ 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,
|
||||
@@ -2411,10 +1967,6 @@ 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",
|
||||
@@ -2430,7 +1982,6 @@ 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",
|
||||
|
||||
@@ -8,10 +8,12 @@ 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, worker_jobs,
|
||||
subscriptions as subscription_service,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,15 +35,6 @@ 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")]
|
||||
@@ -92,11 +85,6 @@ 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,
|
||||
@@ -144,12 +132,6 @@ 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();
|
||||
@@ -172,12 +154,7 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -190,31 +167,17 @@ 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]
|
||||
@@ -224,9 +187,7 @@ 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<_>>())
|
||||
}
|
||||
|
||||
@@ -253,7 +214,8 @@ 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),
|
||||
@@ -296,8 +258,7 @@ 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?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,9 +280,7 @@ 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)),
|
||||
@@ -449,13 +408,6 @@ 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),
|
||||
@@ -467,12 +419,7 @@ pub async fn test_subscription(
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(serde_json::json!({
|
||||
"queued": true,
|
||||
"id": item.id,
|
||||
"delivery_id": delivery.id,
|
||||
"job_id": job.as_ref().map(|value| value.id),
|
||||
}))
|
||||
format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -482,9 +429,7 @@ 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,
|
||||
@@ -505,162 +450,6 @@ 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,
|
||||
@@ -687,30 +476,11 @@ 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))
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ use async_stream::stream;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http::{
|
||||
HeaderMap, HeaderValue,
|
||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||
HeaderMap, HeaderValue,
|
||||
},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
@@ -8,11 +8,10 @@ use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, rejection::ExtensionRejection},
|
||||
http::{HeaderMap, header},
|
||||
extract::{rejection::ExtensionRejection, ConnectInfo},
|
||||
http::{header, HeaderMap},
|
||||
};
|
||||
|
||||
use crate::controllers::admin::check_auth;
|
||||
use crate::models::_entities::{
|
||||
comments::{ActiveModel, Column, Entity, Model},
|
||||
posts,
|
||||
@@ -22,6 +21,7 @@ use crate::services::{
|
||||
comment_guard::{self, CommentGuardInput},
|
||||
notifications,
|
||||
};
|
||||
use crate::controllers::admin::check_auth;
|
||||
|
||||
const ARTICLE_SCOPE: &str = "article";
|
||||
const PARAGRAPH_SCOPE: &str = "paragraph";
|
||||
|
||||
@@ -38,15 +38,8 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,9 +127,7 @@ 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?;
|
||||
@@ -144,10 +142,7 @@ 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,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
pub mod admin;
|
||||
pub mod admin_api;
|
||||
pub mod admin_ops;
|
||||
pub mod admin_taxonomy;
|
||||
pub mod admin_ops;
|
||||
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;
|
||||
|
||||
@@ -7,23 +7,12 @@ use sea_orm::{EntityTrait, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
controllers::admin::{check_auth, resolve_admin_identity},
|
||||
controllers::admin::check_auth,
|
||||
models::_entities::reviews::{self, Entity as ReviewEntity},
|
||||
services::{admin_audit, storage},
|
||||
};
|
||||
|
||||
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)]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CreateReviewRequest {
|
||||
pub title: String,
|
||||
pub review_type: String,
|
||||
@@ -36,7 +25,7 @@ pub struct CreateReviewRequest {
|
||||
pub link_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UpdateReviewRequest {
|
||||
pub title: Option<String>,
|
||||
pub review_type: Option<String>,
|
||||
@@ -49,32 +38,23 @@ pub struct UpdateReviewRequest {
|
||||
pub link_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
|
||||
let include_private = resolve_admin_identity(&headers).is_some();
|
||||
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
|
||||
let reviews = ReviewEntity::find()
|
||||
.order_by_desc(reviews::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|review| include_private || is_public_review_status(review.status.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
.await?;
|
||||
|
||||
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) if include_private || is_public_review_status(r.status.as_deref()) => {
|
||||
format::json(r)
|
||||
}
|
||||
Some(_) => Err(Error::NotFound),
|
||||
Some(r) => format::json(r),
|
||||
None => Err(Error::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
@@ -90,12 +89,6 @@ 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")]
|
||||
@@ -160,14 +153,10 @@ 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")]
|
||||
@@ -208,7 +197,6 @@ 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,
|
||||
@@ -222,35 +210,8 @@ 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)]
|
||||
@@ -288,51 +249,6 @@ 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();
|
||||
@@ -353,7 +269,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 {
|
||||
@@ -636,15 +552,6 @@ 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);
|
||||
}
|
||||
@@ -779,9 +686,6 @@ 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));
|
||||
}
|
||||
@@ -789,9 +693,6 @@ 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));
|
||||
@@ -845,10 +746,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("欢迎来到 InitCool".to_string()),
|
||||
hero_subtitle: Some("记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。".to_string()),
|
||||
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
|
||||
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()),
|
||||
hero_title: Some("欢迎来到我的极客终端博客".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(
|
||||
@@ -906,9 +807,6 @@ 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(
|
||||
@@ -948,10 +846,8 @@ 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),
|
||||
@@ -1020,7 +916,6 @@ 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(),
|
||||
@@ -1048,10 +943,8 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,50 +1011,6 @@ 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(¤t);
|
||||
let access_granted = if enabled {
|
||||
validate_maintenance_access_token(¤t, 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(¤t);
|
||||
|
||||
if !enabled {
|
||||
return format::json(MaintenanceVerifyResponse {
|
||||
maintenance_mode_enabled: false,
|
||||
access_granted: true,
|
||||
access_token: None,
|
||||
});
|
||||
}
|
||||
|
||||
let access_token = verify_maintenance_access_code(¤t, 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,
|
||||
@@ -1182,8 +1031,6 @@ 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))
|
||||
|
||||
@@ -33,26 +33,6 @@ 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,
|
||||
@@ -83,21 +63,6 @@ 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,
|
||||
@@ -124,30 +89,6 @@ 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>,
|
||||
@@ -178,7 +119,11 @@ 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,
|
||||
@@ -241,9 +186,7 @@ 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
|
||||
@@ -253,11 +196,15 @@ pub async fn subscribe_browser_push(
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
abuse_guard::enforce_public_scope(
|
||||
"browser-push-subscription",
|
||||
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(),
|
||||
client_ip.as_deref(),
|
||||
Some(&endpoint),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = subscriptions::create_public_web_push_subscription(
|
||||
&ctx,
|
||||
@@ -293,174 +240,6 @@ 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>,
|
||||
@@ -554,7 +333,6 @@ 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))
|
||||
|
||||
@@ -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: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。"
|
||||
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
|
||||
approved: true
|
||||
|
||||
- id: 5
|
||||
pid: 4
|
||||
author: "清嘉"
|
||||
email: "qingjia@example.com"
|
||||
content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。"
|
||||
content: "黄山记的序文很适合测试首屏摘要生成。"
|
||||
approved: true
|
||||
|
||||
- id: 6
|
||||
|
||||
@@ -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 @@
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 山水游记
|
||||
- 长文测试
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
rating: 5
|
||||
review_date: "2024-02-18"
|
||||
status: "published"
|
||||
description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。"
|
||||
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。"
|
||||
tags: ["经济", "非虚构", "中国"]
|
||||
cover: "/review-covers/placed-within.svg"
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
site_name: "InitCool"
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://init.cool"
|
||||
site_title: "InitCool · 技术笔记与内容档案"
|
||||
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
|
||||
hero_title: "欢迎来到 InitCool"
|
||||
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
|
||||
site_title: "InitCool · 中文长文与 AI 搜索实验站"
|
||||
site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。"
|
||||
hero_title: "欢迎来到我的中文内容实验站"
|
||||
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
@@ -43,9 +43,6 @@
|
||||
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"
|
||||
@@ -60,4 +57,3 @@
|
||||
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
|
||||
ai_top_k: 4
|
||||
ai_chunk_size: 1200
|
||||
seo_favicon_url: null
|
||||
|
||||
@@ -108,25 +108,19 @@ 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)
|
||||
@@ -188,18 +182,6 @@ 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());
|
||||
}
|
||||
@@ -279,10 +261,6 @@ 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()
|
||||
|
||||
@@ -20,4 +20,3 @@ pub mod site_settings;
|
||||
pub mod subscriptions;
|
||||
pub mod tags;
|
||||
pub mod users;
|
||||
pub mod worker_jobs;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
pub use super::admin_audit_logs::Entity as AdminAuditLogs;
|
||||
pub use super::ai_chunks::Entity as AiChunks;
|
||||
pub use super::admin_audit_logs::Entity as AdminAuditLogs;
|
||||
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,4 +18,3 @@ 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;
|
||||
|
||||
@@ -30,10 +30,6 @@ 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>,
|
||||
@@ -78,11 +74,8 @@ 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>,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//! `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 {}
|
||||
@@ -1,5 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, offset::Local};
|
||||
use chrono::{offset::Local, Duration};
|
||||
use loco_rs::{auth::jwt, hash, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Map;
|
||||
|
||||
@@ -3,9 +3,12 @@ use std::{
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
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;
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
use loco_rs::prelude::{AppContext, Result};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
|
||||
use crate::controllers::admin::AdminIdentity;
|
||||
use crate::{
|
||||
controllers::admin::AdminIdentity,
|
||||
models::_entities::admin_audit_logs,
|
||||
};
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -140,8 +140,6 @@ 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>,
|
||||
}
|
||||
@@ -199,109 +197,16 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
|
||||
value.format("%Y-%m-%d %H:%M").to_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 {
|
||||
fn normalize_referrer_source(value: Option<String>) -> String {
|
||||
let Some(value) = trim_to_option(value) else {
|
||||
return "direct".to_string();
|
||||
};
|
||||
|
||||
let normalized = reqwest::Url::parse(&value)
|
||||
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> {
|
||||
@@ -645,11 +550,7 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
page_views_last_24h += 1;
|
||||
}
|
||||
|
||||
let referrer = normalize_tracking_source(
|
||||
Some(&event.path),
|
||||
event.referrer.clone(),
|
||||
event.metadata.as_ref(),
|
||||
);
|
||||
let referrer = normalize_referrer_source(event.referrer.clone());
|
||||
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
@@ -736,29 +637,22 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
});
|
||||
providers_last_7d.truncate(6);
|
||||
|
||||
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 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 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)
|
||||
@@ -854,8 +748,6 @@ 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,
|
||||
})
|
||||
@@ -1029,8 +921,7 @@ 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<_>>();
|
||||
@@ -1097,22 +988,8 @@ 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),
|
||||
])
|
||||
}
|
||||
@@ -1259,8 +1136,7 @@ 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<_>>();
|
||||
|
||||
@@ -30,23 +30,16 @@ struct MarkdownFrontmatter {
|
||||
deserialize_with = "deserialize_optional_string_list"
|
||||
)]
|
||||
categories: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "tag",
|
||||
deserialize_with = "deserialize_optional_string_list"
|
||||
)]
|
||||
#[serde(default, 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>,
|
||||
@@ -240,18 +233,6 @@ 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>>,
|
||||
@@ -519,7 +500,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: resolve_post_visibility(&frontmatter),
|
||||
visibility: normalize_post_visibility(frontmatter.visibility.as_deref()),
|
||||
publish_at: format_frontmatter_datetime(parse_frontmatter_datetime(
|
||||
frontmatter.publish_at.clone(),
|
||||
)),
|
||||
@@ -1171,39 +1152,3 @@ 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod abuse_guard;
|
||||
pub mod admin_audit;
|
||||
pub mod abuse_guard;
|
||||
pub mod ai;
|
||||
pub mod analytics;
|
||||
pub mod backups;
|
||||
@@ -12,4 +12,3 @@ pub mod storage;
|
||||
pub mod subscriptions;
|
||||
pub mod turnstile;
|
||||
pub mod web_push;
|
||||
pub mod worker_jobs;
|
||||
|
||||
@@ -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,16 +71,10 @@ 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()),
|
||||
);
|
||||
|
||||
@@ -141,13 +135,9 @@ 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()),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_s3::{Client, config::Credentials, primitives::ByteStream};
|
||||
use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, QueryOrder};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use loco_rs::prelude::*;
|
||||
use loco_rs::{
|
||||
bgworker::BackgroundWorker,
|
||||
prelude::*,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder,
|
||||
@@ -12,7 +15,10 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
mailers::subscription::SubscriptionMailer,
|
||||
models::_entities::{notification_deliveries, posts, subscriptions},
|
||||
services::{content, web_push as web_push_service, worker_jobs},
|
||||
services::{content, web_push as web_push_service},
|
||||
workers::notification_delivery::{
|
||||
NotificationDeliveryWorker, NotificationDeliveryWorkerArgs,
|
||||
},
|
||||
};
|
||||
|
||||
pub const CHANNEL_EMAIL: &str = "email";
|
||||
@@ -243,18 +249,11 @@ 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)
|
||||
}
|
||||
@@ -287,8 +286,7 @@ 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()),
|
||||
@@ -306,8 +304,7 @@ 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()),
|
||||
@@ -419,31 +416,19 @@ 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)
|
||||
}
|
||||
@@ -470,11 +455,7 @@ 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;
|
||||
}
|
||||
@@ -512,9 +493,7 @@ fn subscription_allows_event(
|
||||
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;
|
||||
}
|
||||
@@ -528,15 +507,10 @@ 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()) {
|
||||
@@ -803,9 +777,7 @@ 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);
|
||||
}
|
||||
@@ -817,10 +789,7 @@ 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());
|
||||
@@ -858,22 +827,24 @@ 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<()> {
|
||||
let _ = worker_jobs::queue_notification_delivery_job(
|
||||
ctx,
|
||||
delivery_id,
|
||||
None,
|
||||
Some("system".to_string()),
|
||||
None,
|
||||
Some("system".to_string()),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn queue_direct_notification(
|
||||
@@ -978,16 +949,10 @@ 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;
|
||||
|
||||
@@ -1097,32 +1062,38 @@ 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)?;
|
||||
@@ -1174,10 +1145,7 @@ 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(());
|
||||
}
|
||||
|
||||
@@ -1185,19 +1153,15 @@ 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,
|
||||
};
|
||||
|
||||
@@ -1211,13 +1175,7 @@ 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(());
|
||||
}
|
||||
}
|
||||
@@ -1248,14 +1206,7 @@ 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();
|
||||
@@ -1271,8 +1222,7 @@ 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)
|
||||
@@ -1352,10 +1302,7 @@ 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);
|
||||
@@ -1372,17 +1319,13 @@ pub async fn notify_post_published(
|
||||
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(),
|
||||
);
|
||||
|
||||
@@ -1416,8 +1359,7 @@ 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));
|
||||
@@ -1431,14 +1373,7 @@ 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,
|
||||
|
||||
@@ -91,7 +91,8 @@ 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 {
|
||||
@@ -172,10 +173,11 @@ 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),
|
||||
|
||||
@@ -66,7 +66,9 @@ pub fn private_key_configured(settings: &site_settings::Model) -> bool {
|
||||
}
|
||||
|
||||
pub fn is_enabled(settings: &site_settings::Model) -> bool {
|
||||
public_key_configured(settings) && private_key_configured(settings)
|
||||
settings.web_push_enabled.unwrap_or(false)
|
||||
&& public_key_configured(settings)
|
||||
&& private_key_configured(settings)
|
||||
}
|
||||
|
||||
pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result<SubscriptionInfo> {
|
||||
|
||||
@@ -1,866 +0,0 @@
|
||||
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::{
|
||||
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 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_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_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_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_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_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_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_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 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 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 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_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
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -1,355 +1,13 @@
|
||||
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(Clone, Deserialize, Debug, Serialize)]
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
pub struct DownloadWorkerArgs {
|
||||
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),
|
||||
})
|
||||
pub user_guid: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -357,31 +15,9 @@ 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...
|
||||
|
||||
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(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::services::{subscriptions, worker_jobs};
|
||||
use crate::services::subscriptions;
|
||||
|
||||
pub struct NotificationDeliveryWorker {
|
||||
pub ctx: AppContext,
|
||||
@@ -10,8 +10,6 @@ pub struct NotificationDeliveryWorker {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct NotificationDeliveryWorkerArgs {
|
||||
pub delivery_id: i32,
|
||||
#[serde(default)]
|
||||
pub job_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -25,28 +23,6 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
|
||||
}
|
||||
|
||||
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
|
||||
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
|
||||
}
|
||||
subscriptions::process_delivery(&self.ctx, args.delivery_id).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use chrono::{Duration, offset::Local};
|
||||
use chrono::{offset::Local, Duration};
|
||||
use insta::assert_debug_snapshot;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use loco_rs::{TestServer, app::AppContext};
|
||||
use loco_rs::{app::AppContext, TestServer};
|
||||
use termi_api::{models::users, views::auth::LoginResponse};
|
||||
|
||||
const USER_EMAIL: &str = "test@loco.com";
|
||||
|
||||
@@ -18,10 +18,6 @@ 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),
|
||||
|
||||
@@ -68,7 +68,6 @@ 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:
|
||||
|
||||
@@ -13,17 +13,13 @@ COPY . .
|
||||
ARG PUBLIC_API_BASE_URL=http://localhost:5150/api
|
||||
ENV PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL}
|
||||
|
||||
RUN pnpm build \
|
||||
&& pnpm prune --prod
|
||||
RUN pnpm build
|
||||
|
||||
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
|
||||
|
||||
@@ -58,27 +58,3 @@ 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。
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"indexnow:submit": "node ./scripts/submit-indexnow.mjs"
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "^7.0.1",
|
||||
@@ -22,7 +21,6 @@
|
||||
"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"
|
||||
|
||||
148
frontend/pnpm-lock.yaml
generated
148
frontend/pnpm-lock.yaml
generated
@@ -35,9 +35,6 @@ importers:
|
||||
postcss:
|
||||
specifier: ^8.5.8
|
||||
version: 8.5.8
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -841,10 +838,6 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001781:
|
||||
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
|
||||
|
||||
@@ -876,9 +869,6 @@ packages:
|
||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -952,10 +942,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
@@ -997,9 +983,6 @@ packages:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
@@ -1108,10 +1091,6 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
flattie@1.1.1:
|
||||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1285,10 +1264,6 @@ packages:
|
||||
locate-character@3.0.0:
|
||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -1524,18 +1499,10 @@ packages:
|
||||
oniguruma-to-es@4.3.5:
|
||||
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@7.3.0:
|
||||
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-queue@9.1.0:
|
||||
resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -1544,10 +1511,6 @@ packages:
|
||||
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
@@ -1560,10 +1523,6 @@ packages:
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
@@ -1589,10 +1548,6 @@ packages:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -1650,11 +1605,6 @@ packages:
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -1731,9 +1681,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve@1.22.11:
|
||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1782,9 +1729,6 @@ packages:
|
||||
server-destroy@1.0.1:
|
||||
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@@ -2185,17 +2129,10 @@ packages:
|
||||
web-namespaces@2.0.1:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which-pm-runs@1.1.0:
|
||||
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2203,9 +2140,6 @@ packages:
|
||||
xxhash-wasm@1.1.0:
|
||||
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2224,10 +2158,6 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2236,10 +2166,6 @@ packages:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3045,8 +2971,6 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001781: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -3079,12 +3003,6 @@ snapshots:
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -3145,8 +3063,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -3175,8 +3091,6 @@ snapshots:
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
@@ -3292,11 +3206,6 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
flattie@1.1.1: {}
|
||||
|
||||
fontace@0.4.1:
|
||||
@@ -3503,10 +3412,6 @@ snapshots:
|
||||
|
||||
locate-character@3.0.0: {}
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
@@ -3913,18 +3818,10 @@ snapshots:
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@7.3.0:
|
||||
dependencies:
|
||||
yocto-queue: 1.2.2
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-queue@9.1.0:
|
||||
dependencies:
|
||||
eventemitter3: 5.0.4
|
||||
@@ -3932,8 +3829,6 @@ snapshots:
|
||||
|
||||
p-timeout@7.0.1: {}
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
parse-latin@7.0.0:
|
||||
@@ -3951,8 +3846,6 @@ snapshots:
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
piccolore@0.1.3: {}
|
||||
@@ -3967,8 +3860,6 @@ snapshots:
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.8):
|
||||
dependencies:
|
||||
postcss: 8.5.8
|
||||
@@ -4017,12 +3908,6 @@ snapshots:
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
radix3@1.1.2: {}
|
||||
@@ -4125,8 +4010,6 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
@@ -4219,8 +4102,6 @@ snapshots:
|
||||
|
||||
server-destroy@1.0.1: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
@@ -4628,16 +4509,8 @@ snapshots:
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which-pm-runs@1.1.0: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -4646,8 +4519,6 @@ snapshots:
|
||||
|
||||
xxhash-wasm@1.1.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yaml-language-server@1.20.0:
|
||||
@@ -4668,29 +4539,10 @@ snapshots:
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
@@ -1,134 +0,0 @@
|
||||
const DEFAULT_API_PATH = '/api'
|
||||
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow'
|
||||
|
||||
function normalizeBase(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function ensureAbsolute(base, path) {
|
||||
return `${normalizeBase(base)}${String(path).startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function collectStaticRoutes(siteSettings) {
|
||||
const routes = [
|
||||
'/',
|
||||
'/about',
|
||||
'/articles',
|
||||
'/categories',
|
||||
'/tags',
|
||||
'/timeline',
|
||||
'/reviews',
|
||||
'/friends',
|
||||
'/rss.xml',
|
||||
'/sitemap.xml',
|
||||
'/llms.txt',
|
||||
'/llms-full.txt',
|
||||
'/indexnow-key.txt',
|
||||
]
|
||||
|
||||
if (siteSettings?.ai_enabled || siteSettings?.ai?.enabled) {
|
||||
routes.push('/ask')
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const indexNowKey = String(process.env.INDEXNOW_KEY || '').trim()
|
||||
if (!indexNowKey) {
|
||||
throw new Error('Missing INDEXNOW_KEY environment variable.')
|
||||
}
|
||||
|
||||
const configuredSiteUrl = normalizeBase(process.env.SITE_URL || process.env.PUBLIC_SITE_URL || '')
|
||||
const configuredApiBaseUrl = normalizeBase(
|
||||
process.env.INTERNAL_API_BASE_URL || process.env.PUBLIC_API_BASE_URL || '',
|
||||
)
|
||||
|
||||
const bootstrapApiBaseUrl = configuredApiBaseUrl || `${configuredSiteUrl}${DEFAULT_API_PATH}`
|
||||
if (!bootstrapApiBaseUrl) {
|
||||
throw new Error('Missing SITE_URL/PUBLIC_SITE_URL or API base URL for IndexNow submission.')
|
||||
}
|
||||
|
||||
const siteSettings = await fetchJson(`${bootstrapApiBaseUrl}/site_settings`).catch(() => null)
|
||||
const siteUrl = normalizeBase(configuredSiteUrl || siteSettings?.site_url || '')
|
||||
if (!siteUrl) {
|
||||
throw new Error('Unable to determine canonical SITE_URL for IndexNow submission.')
|
||||
}
|
||||
|
||||
const apiBaseUrl = configuredApiBaseUrl || `${siteUrl}${DEFAULT_API_PATH}`
|
||||
|
||||
const [posts, categories, tags, reviews] = await Promise.all([
|
||||
fetchJson(`${apiBaseUrl}/posts`).catch(() => []),
|
||||
fetchJson(`${apiBaseUrl}/categories`).catch(() => []),
|
||||
fetchJson(`${apiBaseUrl}/tags`).catch(() => []),
|
||||
fetchJson(`${apiBaseUrl}/reviews`).catch(() => []),
|
||||
])
|
||||
|
||||
const urls = new Set(collectStaticRoutes(siteSettings).map((path) => ensureAbsolute(siteUrl, path)))
|
||||
|
||||
for (const post of posts) {
|
||||
if (!post?.slug || post?.noindex === true) continue
|
||||
urls.add(ensureAbsolute(siteUrl, `/articles/${encodeURIComponent(post.slug)}`))
|
||||
}
|
||||
|
||||
for (const category of categories) {
|
||||
const token = category?.slug || category?.name
|
||||
if (!token) continue
|
||||
urls.add(ensureAbsolute(siteUrl, `/categories/${encodeURIComponent(token)}`))
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const token = tag?.slug || tag?.name
|
||||
if (!token) continue
|
||||
urls.add(ensureAbsolute(siteUrl, `/tags/${encodeURIComponent(token)}`))
|
||||
}
|
||||
|
||||
for (const review of reviews) {
|
||||
if (!review?.id) continue
|
||||
urls.add(ensureAbsolute(siteUrl, `/reviews/${review.id}`))
|
||||
}
|
||||
|
||||
const payload = {
|
||||
host: new URL(siteUrl).host,
|
||||
key: indexNowKey,
|
||||
keyLocation: ensureAbsolute(siteUrl, '/indexnow-key.txt'),
|
||||
urlList: [...urls],
|
||||
}
|
||||
|
||||
const response = await fetch(INDEXNOW_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const responseText = await response.text().catch(() => '')
|
||||
if (!response.ok) {
|
||||
throw new Error(`IndexNow submission failed: ${response.status} ${response.statusText}\n${responseText}`)
|
||||
}
|
||||
|
||||
console.log(`IndexNow submitted ${payload.urlList.length} URLs for ${siteUrl}`)
|
||||
if (responseText) {
|
||||
console.log(responseText)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -516,7 +516,4 @@ function formatCommentDate(dateStr: string): string {
|
||||
} else if (useCaptcha) {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
|
||||
wrapper?.setAttribute('data-comments-ready', 'true');
|
||||
window.__termiCommentsReady = true;
|
||||
</script>
|
||||
|
||||
@@ -16,8 +16,7 @@ const {
|
||||
|
||||
const { locale, t, buildLocaleUrl } = getI18n(Astro);
|
||||
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
|
||||
const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true;
|
||||
const musicPlaylist = (musicEnabled ? Astro.props.siteSettings?.musicPlaylist : []).filter(
|
||||
const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter(
|
||||
(item) => item?.title?.trim() && item?.url?.trim()
|
||||
);
|
||||
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
|
||||
@@ -61,11 +60,11 @@ const currentNavLabel =
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div class="relative hidden min-w-[20rem] grow basis-[24rem] lg:block xl:min-w-[24rem] xl:basis-[30rem]">
|
||||
<div class="terminal-toolbar-module min-w-0 gap-2 px-2.5 py-1.5">
|
||||
<div class="terminal-toolbar-label shrink-0 whitespace-nowrap" id="search-label">{t('header.searchPromptKeyword')}</div>
|
||||
<div class="relative hidden lg:block flex-1 min-w-0 max-w-[16rem] xl:max-w-[18rem]">
|
||||
<div class="terminal-toolbar-module gap-2 px-2.5 py-1.5">
|
||||
<div class="terminal-toolbar-label" id="search-label">{t('header.searchPromptKeyword')}</div>
|
||||
{aiEnabled && (
|
||||
<div id="search-mode-panel" class="hidden shrink-0 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<div id="search-mode-panel" class="hidden 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
@@ -92,7 +91,7 @@ const currentNavLabel =
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8 shrink-0" aria-label="Search">
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8" aria-label="Search">
|
||||
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -105,89 +104,73 @@ const currentNavLabel =
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto hidden shrink-0 items-center gap-2 lg:flex">
|
||||
{musicEnabled && (
|
||||
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
||||
<img
|
||||
id="desktop-music-cover"
|
||||
src={currentMusicTrack?.coverImageUrl || ''}
|
||||
alt={currentMusicTrack?.title || 'Music cover'}
|
||||
class:list={[
|
||||
'h-full w-full object-cover',
|
||||
!currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
/>
|
||||
<i
|
||||
id="desktop-music-cover-fallback"
|
||||
class:list={[
|
||||
'fas fa-compact-disc text-sm text-[var(--primary)]',
|
||||
currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-[11px]"></i>
|
||||
</button>
|
||||
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
|
||||
</button>
|
||||
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-[11px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiEnabled && (
|
||||
<a
|
||||
href="/ask"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
|
||||
>
|
||||
<i class="fas fa-robot text-sm"></i>
|
||||
<span class="hidden xl:inline">{t('nav.ask')}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
|
||||
item.locale === locale
|
||||
? 'bg-[var(--primary)] text-white shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
|
||||
]}
|
||||
aria-current={item.locale === locale ? 'true' : undefined}
|
||||
title={item.label}
|
||||
>
|
||||
{item.shortLabel}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<ThemeToggle
|
||||
client:load
|
||||
labels={{
|
||||
toggle: t('header.themeToggle'),
|
||||
system: t('header.themeSystem'),
|
||||
light: t('header.themeLight'),
|
||||
dark: t('header.themeDark'),
|
||||
}}
|
||||
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
||||
<img
|
||||
id="desktop-music-cover"
|
||||
src={currentMusicTrack?.coverImageUrl || ''}
|
||||
alt={currentMusicTrack?.title || 'Music cover'}
|
||||
class:list={[
|
||||
'h-full w-full object-cover',
|
||||
!currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
/>
|
||||
<i
|
||||
id="desktop-music-cover-fallback"
|
||||
class:list={[
|
||||
'fas fa-compact-disc text-sm text-[var(--primary)]',
|
||||
currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-[11px]"></i>
|
||||
</button>
|
||||
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
|
||||
</button>
|
||||
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-[11px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0 lg:hidden">
|
||||
{aiEnabled && (
|
||||
<a
|
||||
href="/ask"
|
||||
class="hidden lg:inline-flex items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
|
||||
>
|
||||
<i class="fas fa-robot text-sm"></i>
|
||||
<span class="hidden xl:inline">{t('nav.ask')}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div class="hidden lg:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
|
||||
item.locale === locale
|
||||
? 'bg-[var(--primary)] text-white shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
|
||||
]}
|
||||
aria-current={item.locale === locale ? 'true' : undefined}
|
||||
title={item.label}
|
||||
>
|
||||
{item.shortLabel}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<ThemeToggle
|
||||
client:load
|
||||
labels={{
|
||||
@@ -279,53 +262,51 @@ const currentNavLabel =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{musicEnabled && (
|
||||
<div class="terminal-toolbar-module items-center gap-3">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
||||
<img
|
||||
id="music-cover"
|
||||
src={currentMusicTrack?.coverImageUrl || ''}
|
||||
alt={currentMusicTrack?.title || 'Music cover'}
|
||||
class:list={[
|
||||
'h-full w-full object-cover',
|
||||
!currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
/>
|
||||
<i
|
||||
id="music-cover-fallback"
|
||||
class:list={[
|
||||
'fas fa-compact-disc text-base text-[var(--primary)]',
|
||||
currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
></i>
|
||||
<div class="terminal-toolbar-module items-center gap-3">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
|
||||
<img
|
||||
id="music-cover"
|
||||
src={currentMusicTrack?.coverImageUrl || ''}
|
||||
alt={currentMusicTrack?.title || 'Music cover'}
|
||||
class:list={[
|
||||
'h-full w-full object-cover',
|
||||
!currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
/>
|
||||
<i
|
||||
id="music-cover-fallback"
|
||||
class:list={[
|
||||
'fas fa-compact-disc text-base text-[var(--primary)]',
|
||||
currentMusicTrack?.coverImageUrl && 'hidden'
|
||||
]}
|
||||
></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-xs" id="music-play-icon"></i>
|
||||
</button>
|
||||
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-volume-up text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-xs" id="music-play-icon"></i>
|
||||
</button>
|
||||
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-volume-up text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
|
||||
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2 min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
|
||||
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -339,13 +320,13 @@ const currentNavLabel =
|
||||
: ''
|
||||
]}
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/82 text-[var(--primary)]">
|
||||
<i class={`fas ${item.icon} text-sm`}></i>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="terminal-toolbar-label block">{t('header.navigation')}</span>
|
||||
<span class="mt-1 block truncate text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
|
||||
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
|
||||
</span>
|
||||
</span>
|
||||
<i class="fas fa-arrow-right text-[11px] text-[var(--text-tertiary)]"></i>
|
||||
|
||||
@@ -4,8 +4,6 @@ import CodeBlock from './CodeBlock.astro';
|
||||
import ResponsiveImage from './ui/ResponsiveImage.astro';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import {
|
||||
buildCategoryUrl,
|
||||
buildTagUrl,
|
||||
getAccentVars,
|
||||
getCategoryTheme,
|
||||
getPostTypeColor,
|
||||
@@ -18,10 +16,10 @@ interface Props {
|
||||
post: Post;
|
||||
selectedTag?: string;
|
||||
highlightTerm?: string;
|
||||
tagHrefPrefix?: string | null;
|
||||
tagHrefPrefix?: string;
|
||||
}
|
||||
|
||||
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = null } = Astro.props;
|
||||
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = '/tags?tag=' } = Astro.props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
const typeColor = getPostTypeColor(post.type);
|
||||
@@ -51,8 +49,6 @@ const highlightText = (value: string, query: string) => {
|
||||
};
|
||||
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const resolveTagHref = (tag: string) =>
|
||||
tagHrefPrefix ? `${tagHrefPrefix}${encodeURIComponent(tag)}` : buildTagUrl(tag);
|
||||
---
|
||||
|
||||
<article
|
||||
@@ -81,13 +77,9 @@ const resolveTagHref = (tag: string) =>
|
||||
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={buildCategoryUrl(post.category)}
|
||||
class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5"
|
||||
style={getAccentVars(categoryTheme)}
|
||||
>
|
||||
<span class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5" style={getAccentVars(categoryTheme)}>
|
||||
#{post.category}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]" set:html={highlightText(post.description, highlightTerm)} />
|
||||
@@ -130,7 +122,7 @@ const resolveTagHref = (tag: string) =>
|
||||
<div class="relative z-10 pl-3 flex flex-wrap gap-2">
|
||||
{post.tags?.map(tag => (
|
||||
<a
|
||||
href={resolveTagHref(tag)}
|
||||
href={`${tagHrefPrefix}${encodeURIComponent(tag)}`}
|
||||
class:list={[
|
||||
'terminal-chip text-xs py-1 px-2.5',
|
||||
'terminal-chip--accent',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
145
frontend/src/components/SubscriptionSignup.astro
Normal file
145
frontend/src/components/SubscriptionSignup.astro
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
|
||||
interface Props {
|
||||
requestUrl?: string | URL;
|
||||
}
|
||||
|
||||
const { requestUrl } = Astro.props as Props;
|
||||
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
||||
---
|
||||
|
||||
<section class="terminal-subscribe-card" data-subscribe-root data-api-url={subscribeApiUrl}>
|
||||
<div class="terminal-subscribe-head">
|
||||
<p class="terminal-subscribe-kicker">newsletter / notifications</p>
|
||||
<h3>订阅更新</h3>
|
||||
<p>输入邮箱后,可以收到新文章通知;提交后需要先去邮箱点击确认链接才会正式生效。</p>
|
||||
</div>
|
||||
|
||||
<form class="terminal-subscribe-form" data-subscribe-form>
|
||||
<input type="text" name="displayName" placeholder="称呼(可选)" autocomplete="name" />
|
||||
<input type="email" name="email" placeholder="name@example.com" autocomplete="email" required />
|
||||
<button type="submit">订阅</button>
|
||||
</form>
|
||||
|
||||
<p class="terminal-subscribe-status" data-subscribe-status>支持确认订阅、退订链接和偏好管理页。</p>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-subscribe-root]').forEach((root) => {
|
||||
const form = root.querySelector('[data-subscribe-form]');
|
||||
const status = root.querySelector('[data-subscribe-status]');
|
||||
const apiUrl = root.getAttribute('data-api-url');
|
||||
|
||||
if (!(form instanceof HTMLFormElement) || !(status instanceof HTMLElement) || !apiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const email = String(formData.get('email') || '').trim();
|
||||
const displayName = String(formData.get('displayName') || '').trim();
|
||||
|
||||
if (!email) {
|
||||
status.textContent = '请输入邮箱地址。';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = '提交中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
displayName,
|
||||
source: 'frontend-home',
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.description || '订阅失败,请稍后再试。');
|
||||
}
|
||||
|
||||
form.reset();
|
||||
status.textContent =
|
||||
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.terminal-subscribe-card {
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid rgba(94, 234, 212, 0.16);
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
|
||||
border-radius: 1rem;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-kicker {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--primary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
.terminal-subscribe-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-head p:last-child {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.terminal-subscribe-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-form input {
|
||||
width: 100%;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
color: var(--text-primary);
|
||||
padding: 0.85rem 0.95rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-form button {
|
||||
border: 0;
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.9rem 1rem;
|
||||
font-weight: 600;
|
||||
color: #08111f;
|
||||
background: linear-gradient(135deg, var(--primary), #8b5cf6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-subscribe-status {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.terminal-subscribe-form {
|
||||
grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1.2fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,40 +3,31 @@
|
||||
import { getI18n } from '../lib/i18n';
|
||||
|
||||
const { t } = getI18n(Astro);
|
||||
const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
---
|
||||
|
||||
<aside
|
||||
id="toc-container"
|
||||
class="hidden w-full shrink-0 lg:block lg:w-72"
|
||||
data-has-before-nav={hasBeforeNav ? 'true' : 'false'}
|
||||
>
|
||||
<div class="sticky top-24 space-y-4">
|
||||
<slot name="before-nav" />
|
||||
|
||||
<div id="toc-panel" class="terminal-panel-muted space-y-4">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-terminal"></i>
|
||||
nav stack
|
||||
<aside id="toc-container" class="hidden w-full shrink-0 lg:block lg:w-72">
|
||||
<div class="terminal-panel-muted sticky top-24 space-y-4">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-terminal"></i>
|
||||
nav stack
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-list-ul"></i>
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-list-ul"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
|
||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
||||
{t('toc.intro')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
|
||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
||||
{t('toc.intro')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="toc-nav" class="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto pr-1 text-sm">
|
||||
<!-- TOC items will be generated by JavaScript -->
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav id="toc-nav" class="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto pr-1 text-sm">
|
||||
<!-- TOC items will be generated by JavaScript -->
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -48,13 +39,10 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
||||
|
||||
const headings = content.querySelectorAll('h2, h3');
|
||||
const tocNav = document.getElementById('toc-nav');
|
||||
const tocPanel = document.getElementById('toc-panel');
|
||||
const container = document.getElementById('toc-container');
|
||||
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
|
||||
|
||||
if (!tocNav || headings.length === 0) {
|
||||
if (tocPanel) tocPanel.style.display = 'none';
|
||||
if (container && !hasBeforeNav) container.style.display = 'none';
|
||||
const container = document.getElementById('toc-container');
|
||||
if (container) container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
|
||||
type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
badge?: string;
|
||||
kicker?: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
highlights?: string[];
|
||||
faqs?: FaqItem[];
|
||||
}
|
||||
|
||||
const {
|
||||
badge = 'ai brief',
|
||||
kicker = 'geo / summary',
|
||||
title,
|
||||
summary,
|
||||
highlights = [],
|
||||
faqs = [],
|
||||
} = Astro.props as Props;
|
||||
const { locale } = getI18n(Astro);
|
||||
const isEnglish = locale.startsWith('en');
|
||||
---
|
||||
|
||||
<section class="sr-only" data-discovery-brief>
|
||||
<p>{badge}</p>
|
||||
<p>{kicker}</p>
|
||||
<h3>{title}</h3>
|
||||
<p>{summary}</p>
|
||||
|
||||
<div>
|
||||
<h4>{isEnglish ? 'Key signals' : '关键信号'}</h4>
|
||||
{highlights.length > 0 ? (
|
||||
<ul>
|
||||
{highlights.map((item) => (
|
||||
<li>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{isEnglish ? 'FAQ' : '常见问答'}</h4>
|
||||
{faqs.length > 0 ? (
|
||||
<div>
|
||||
{faqs.slice(0, 3).map((item) => (
|
||||
<article>
|
||||
<p>{item.question}</p>
|
||||
<p>{item.answer}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
pageType: string;
|
||||
entityId?: string;
|
||||
postSlug?: string;
|
||||
}
|
||||
|
||||
const props = Astro.props;
|
||||
const pageType = props.pageType;
|
||||
const entityId = props.entityId ?? '';
|
||||
const postSlug = props.postSlug ?? '';
|
||||
---
|
||||
|
||||
<script is:inline define:vars={{ pageType, entityId, postSlug }}>
|
||||
(() => {
|
||||
const endpoint = '/api/analytics/content';
|
||||
const storageKey = `termi:pageview:${pageType}:${entityId || postSlug || 'root'}`;
|
||||
|
||||
function ensureSessionId() {
|
||||
try {
|
||||
const existing = window.sessionStorage.getItem(storageKey);
|
||||
if (existing) return existing;
|
||||
const nextId = crypto.randomUUID();
|
||||
window.sessionStorage.setItem(storageKey, nextId);
|
||||
return nextId;
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getReferrerHost() {
|
||||
try {
|
||||
return document.referrer ? new URL(document.referrer).host : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSource(value) {
|
||||
const source = String(value || '').trim().toLowerCase();
|
||||
if (!source) return 'direct';
|
||||
if (source.includes('chatgpt') || source.includes('openai')) return 'chatgpt-search';
|
||||
if (source.includes('perplexity')) return 'perplexity';
|
||||
if (source.includes('copilot') || source.includes('bing')) return 'copilot-bing';
|
||||
if (source.includes('gemini')) return 'gemini';
|
||||
if (source.includes('google')) return 'google';
|
||||
if (source.includes('claude')) return 'claude';
|
||||
if (source.includes('duckduckgo')) return 'duckduckgo';
|
||||
if (source.includes('kagi')) return 'kagi';
|
||||
return source;
|
||||
}
|
||||
|
||||
function buildMetadata() {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const utmSource = currentUrl.searchParams.get('utm_source')?.trim() || '';
|
||||
const utmMedium = currentUrl.searchParams.get('utm_medium')?.trim() || '';
|
||||
const utmCampaign = currentUrl.searchParams.get('utm_campaign')?.trim() || '';
|
||||
const utmTerm = currentUrl.searchParams.get('utm_term')?.trim() || '';
|
||||
const utmContent = currentUrl.searchParams.get('utm_content')?.trim() || '';
|
||||
const referrerHost = getReferrerHost();
|
||||
|
||||
return {
|
||||
pageType,
|
||||
entityId: entityId || undefined,
|
||||
referrerHost: referrerHost || undefined,
|
||||
utmSource: utmSource || undefined,
|
||||
utmMedium: utmMedium || undefined,
|
||||
utmCampaign: utmCampaign || undefined,
|
||||
utmTerm: utmTerm || undefined,
|
||||
utmContent: utmContent || undefined,
|
||||
landingSource: normalizeSource(utmSource || referrerHost),
|
||||
};
|
||||
}
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true,
|
||||
body: JSON.stringify({
|
||||
event_type: 'page_view',
|
||||
path: `${window.location.pathname}${window.location.search}`,
|
||||
post_slug: postSlug || undefined,
|
||||
session_id: ensureSessionId(),
|
||||
referrer: document.referrer || undefined,
|
||||
metadata: buildMetadata(),
|
||||
}),
|
||||
}).catch(() => undefined);
|
||||
})();
|
||||
</script>
|
||||
@@ -1,630 +0,0 @@
|
||||
---
|
||||
import QRCode from 'qrcode';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
|
||||
type ShareStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
shareTitle: string;
|
||||
summary: string;
|
||||
canonicalUrl: string;
|
||||
badge?: string;
|
||||
kicker?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
stats?: ShareStat[];
|
||||
wechatShareQrEnabled?: boolean;
|
||||
}
|
||||
|
||||
const { locale, t } = getI18n(Astro);
|
||||
const isEnglish = locale.startsWith('en');
|
||||
|
||||
const {
|
||||
shareTitle,
|
||||
summary,
|
||||
canonicalUrl,
|
||||
badge = isEnglish ? 'page share' : '页面分享',
|
||||
title = isEnglish ? 'Quick share' : '一键分享',
|
||||
description = isEnglish
|
||||
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
|
||||
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
|
||||
stats = [],
|
||||
wechatShareQrEnabled = false,
|
||||
} = Astro.props as Props;
|
||||
|
||||
const visibleBadge = badge;
|
||||
const visibleTitle = title;
|
||||
const visibleDescription = description;
|
||||
|
||||
const copy = isEnglish
|
||||
? {
|
||||
summaryTitle: 'Page summary',
|
||||
canonical: 'Page link',
|
||||
copySummary: 'Copy note',
|
||||
copySummarySuccess: 'Share note copied',
|
||||
copySummaryFailed: 'Copy failed',
|
||||
copyLink: 'Copy permalink',
|
||||
copyLinkSuccess: 'Permalink copied',
|
||||
copyLinkFailed: 'Permalink copy failed',
|
||||
shareSummary: 'Share summary',
|
||||
shareSuccess: 'Share panel opened',
|
||||
shareFallback: 'Share text copied',
|
||||
shareFailed: 'Share failed',
|
||||
shareToX: 'Share to X',
|
||||
shareToTelegram: 'Share to Telegram',
|
||||
shareToWeChat: 'WeChat QR',
|
||||
qrModalTitle: 'WeChat scan share',
|
||||
qrModalDescription: 'Scan this QR code in WeChat to open the current page on mobile.',
|
||||
qrModalHint: 'Sharing the page link is enough for others to continue from here.',
|
||||
downloadQr: 'Download QR',
|
||||
downloadQrStarted: 'QR download started',
|
||||
qrOpened: 'WeChat QR ready',
|
||||
toastSuccessTitle: 'Done',
|
||||
toastErrorTitle: 'Action failed',
|
||||
toastInfoTitle: 'Share ready',
|
||||
}
|
||||
: {
|
||||
summaryTitle: '页面简介',
|
||||
canonical: '固定链接',
|
||||
copySummary: '复制简介',
|
||||
copySummarySuccess: '页面简介已复制',
|
||||
copySummaryFailed: '复制失败',
|
||||
copyLink: '复制固定链接',
|
||||
copyLinkSuccess: '固定链接已复制',
|
||||
copyLinkFailed: '固定链接复制失败',
|
||||
shareSummary: '直接分享',
|
||||
shareSuccess: '已打开系统分享',
|
||||
shareFallback: '分享内容已复制',
|
||||
shareFailed: '分享失败',
|
||||
shareToX: '分享到 X',
|
||||
shareToTelegram: '分享到 Telegram',
|
||||
shareToWeChat: '微信扫一扫',
|
||||
qrModalTitle: '微信扫一扫',
|
||||
qrModalDescription: '用微信扫一扫,就能在手机上继续浏览当前页面。',
|
||||
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。',
|
||||
downloadQr: '下载二维码',
|
||||
downloadQrStarted: '二维码开始下载',
|
||||
qrOpened: '微信二维码已打开',
|
||||
toastSuccessTitle: '操作完成',
|
||||
toastErrorTitle: '操作失败',
|
||||
toastInfoTitle: '已准备好',
|
||||
};
|
||||
|
||||
const safeSummary = summary.trim() || shareTitle;
|
||||
const panelIdSeed = `${shareTitle}-${canonicalUrl}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const panelId = `share-${panelIdSeed.slice(0, 48) || 'panel'}`;
|
||||
const shareClipboardText = [shareTitle, safeSummary, `${copy.canonical}: ${canonicalUrl}`]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const shareSummaryText = [shareTitle, safeSummary, canonicalUrl].filter(Boolean).join('\n\n');
|
||||
const shareTeaser = [shareTitle, safeSummary].filter(Boolean).join(' — ').slice(0, 220);
|
||||
const xShareUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(shareTeaser)}&url=${encodeURIComponent(canonicalUrl)}`;
|
||||
const telegramShareUrl = `https://t.me/share/url?url=${encodeURIComponent(canonicalUrl)}&text=${encodeURIComponent(shareTeaser)}`;
|
||||
|
||||
let wechatShareQrSvg = '';
|
||||
let wechatShareQrPngDataUrl = '';
|
||||
if (wechatShareQrEnabled) {
|
||||
try {
|
||||
wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
width: 240,
|
||||
color: {
|
||||
dark: '#111827',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
|
||||
margin: 1,
|
||||
width: 420,
|
||||
color: {
|
||||
dark: '#111827',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Share panel QR generation error:', error);
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<section
|
||||
class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.1),rgba(var(--secondary-rgb),0.04)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6"
|
||||
data-share-panel-id={panelId}
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
||||
<i class="fas fa-satellite-dish text-[10px]"></i>
|
||||
{visibleBadge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">{visibleTitle}</h3>
|
||||
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{visibleDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:min-w-[16rem]">
|
||||
{stats.slice(0, 4).map((item) => (
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_16px_40px_rgba(15,23,42,0.06)]">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
|
||||
<p class="mt-3 text-base leading-8 text-[var(--title-color)]">{safeSummary}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button"
|
||||
data-share-copy-summary
|
||||
data-default-label={copy.copySummary}
|
||||
data-success-label={copy.copySummarySuccess}
|
||||
data-failed-label={copy.copySummaryFailed}
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
<span>{copy.copySummary}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button"
|
||||
data-share-copy-link
|
||||
data-default-label={copy.copyLink}
|
||||
data-success-label={copy.copyLinkSuccess}
|
||||
data-failed-label={copy.copyLinkFailed}
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{copy.copyLink}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
data-share-summary
|
||||
data-default-label={copy.shareSummary}
|
||||
data-success-label={copy.shareSuccess}
|
||||
data-fallback-label={copy.shareFallback}
|
||||
data-failed-label={copy.shareFailed}
|
||||
>
|
||||
<i class="fas fa-share-nodes"></i>
|
||||
<span>{copy.shareSummary}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||
{isEnglish ? 'Share channels' : '分享渠道'}
|
||||
</p>
|
||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={xShareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="terminal-action-button"
|
||||
data-share-link
|
||||
>
|
||||
<i class="fab fa-twitter"></i>
|
||||
<span>{copy.shareToX}</span>
|
||||
</a>
|
||||
<a
|
||||
href={telegramShareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="terminal-action-button"
|
||||
data-share-link
|
||||
>
|
||||
<i class="fab fa-telegram-plane"></i>
|
||||
<span>{copy.shareToTelegram}</span>
|
||||
</a>
|
||||
{wechatShareQrEnabled && wechatShareQrSvg ? (
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button"
|
||||
data-share-wechat-open
|
||||
>
|
||||
<i class="fab fa-weixin"></i>
|
||||
<span>{copy.shareToWeChat}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.canonical}</div>
|
||||
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||
</div>
|
||||
|
||||
{wechatShareQrEnabled && wechatShareQrSvg ? (
|
||||
<div
|
||||
class="fixed inset-0 z-[160] hidden bg-black/70 backdrop-blur-sm"
|
||||
data-share-wechat-modal
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fab fa-weixin"></i>
|
||||
{copy.shareToWeChat}
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold text-[var(--title-color)]">{copy.qrModalTitle}</h3>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{copy.qrModalDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-share-wechat-close
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<i class="fas fa-xmark text-base"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
|
||||
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
{copy.canonical}
|
||||
</div>
|
||||
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
{copy.summaryTitle}
|
||||
</div>
|
||||
<p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
|
||||
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{safeSummary}</p>
|
||||
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
data-share-copy-link
|
||||
data-default-label={copy.copyLink}
|
||||
data-success-label={copy.copyLinkSuccess}
|
||||
data-failed-label={copy.copyLinkFailed}
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
<span>{copy.copyLink}</span>
|
||||
</button>
|
||||
<a
|
||||
href={wechatShareQrPngDataUrl}
|
||||
download={`${panelId}-wechat-share-qr.png`}
|
||||
class="terminal-action-button"
|
||||
data-share-qr-download
|
||||
>
|
||||
<i class="fas fa-download"></i>
|
||||
<span>{copy.downloadQr}</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button"
|
||||
data-share-wechat-close
|
||||
>
|
||||
<i class="fas fa-xmark"></i>
|
||||
<span>{t('common.close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-5 right-5 z-[70] w-[min(22rem,calc(100vw-1.5rem))] translate-y-4 opacity-0 transition-all duration-300"
|
||||
data-share-toast
|
||||
data-title-success={copy.toastSuccessTitle}
|
||||
data-title-error={copy.toastErrorTitle}
|
||||
data-title-info={copy.toastInfoTitle}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="rounded-2xl border border-emerald-500/25 bg-[var(--terminal-bg)]/96 p-4 shadow-[0_18px_50px_rgba(15,23,42,0.18)] backdrop-blur">
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500/12 text-emerald-400"
|
||||
data-share-toast-icon
|
||||
>
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-[var(--title-color)]" data-share-toast-title>
|
||||
{copy.toastSuccessTitle}
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]" data-share-toast-message></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
panelId,
|
||||
shareClipboardText,
|
||||
shareSummaryText,
|
||||
canonicalUrl,
|
||||
shareTitle,
|
||||
qrOpenedLabel: copy.qrOpened,
|
||||
qrDownloadStartedLabel: copy.downloadQrStarted,
|
||||
}}
|
||||
>
|
||||
(() => {
|
||||
const root = document.querySelector(`[data-share-panel-id="${panelId}"]`);
|
||||
if (!root) return;
|
||||
|
||||
const copySummaryButton = root.querySelector('[data-share-copy-summary]');
|
||||
const shareSummaryButton = root.querySelector('[data-share-summary]');
|
||||
const copyLinkButtons = root.querySelectorAll('[data-share-copy-link]');
|
||||
const shareLinks = root.querySelectorAll('[data-share-link]');
|
||||
const wechatOpenButtons = root.querySelectorAll('[data-share-wechat-open]');
|
||||
const wechatCloseButtons = root.querySelectorAll('[data-share-wechat-close]');
|
||||
const wechatModal = root.querySelector('[data-share-wechat-modal]');
|
||||
const qrDownloadButtons = root.querySelectorAll('[data-share-qr-download]');
|
||||
const status = root.querySelector('[data-share-status]');
|
||||
const toast = root.querySelector('[data-share-toast]');
|
||||
const toastIcon = root.querySelector('[data-share-toast-icon]');
|
||||
const toastTitle = root.querySelector('[data-share-toast-title]');
|
||||
const toastMessage = root.querySelector('[data-share-toast-message]');
|
||||
let toastTimer = 0;
|
||||
|
||||
function setStatus(message) {
|
||||
if (!status) return;
|
||||
status.textContent = message || '';
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
if (!toast || !toastIcon || !toastTitle || !toastMessage) return;
|
||||
|
||||
const title =
|
||||
toast.getAttribute(`data-title-${type}`) ||
|
||||
toast.getAttribute('data-title-success') ||
|
||||
'';
|
||||
|
||||
toastTitle.textContent = title;
|
||||
toastMessage.textContent = message || '';
|
||||
toast.classList.remove('opacity-0', 'translate-y-4');
|
||||
|
||||
toastIcon.className = 'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl';
|
||||
const iconElement = toastIcon.querySelector('i');
|
||||
if (iconElement) {
|
||||
iconElement.className =
|
||||
type === 'error'
|
||||
? 'fas fa-triangle-exclamation'
|
||||
: type === 'info'
|
||||
? 'fas fa-share-nodes'
|
||||
: 'fas fa-check';
|
||||
}
|
||||
|
||||
const toastCard = toast.firstElementChild;
|
||||
toastCard?.classList.remove('border-emerald-500/25', 'border-rose-500/25', 'border-sky-500/25');
|
||||
toastIcon.classList.remove(
|
||||
'bg-emerald-500/12',
|
||||
'text-emerald-400',
|
||||
'bg-rose-500/12',
|
||||
'text-rose-400',
|
||||
'bg-sky-500/12',
|
||||
'text-sky-400',
|
||||
);
|
||||
|
||||
if (type === 'error') {
|
||||
toastCard?.classList.add('border-rose-500/25');
|
||||
toastIcon.classList.add('bg-rose-500/12', 'text-rose-400');
|
||||
} else if (type === 'info') {
|
||||
toastCard?.classList.add('border-sky-500/25');
|
||||
toastIcon.classList.add('bg-sky-500/12', 'text-sky-400');
|
||||
} else {
|
||||
toastCard?.classList.add('border-emerald-500/25');
|
||||
toastIcon.classList.add('bg-emerald-500/12', 'text-emerald-400');
|
||||
}
|
||||
|
||||
window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
function setButtonState(button, iconClass, label) {
|
||||
if (!button) return;
|
||||
button.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
|
||||
}
|
||||
|
||||
function resetButton(button, fallbackIconClass) {
|
||||
if (!button) return;
|
||||
const defaultLabel = button.getAttribute('data-default-label') || '';
|
||||
setButtonState(button, fallbackIconClass, defaultLabel);
|
||||
}
|
||||
|
||||
async function writeClipboard(value) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
async function handleCopySummary() {
|
||||
const successLabel = copySummaryButton?.getAttribute('data-success-label') || '';
|
||||
const failedLabel = copySummaryButton?.getAttribute('data-failed-label') || '';
|
||||
|
||||
try {
|
||||
await writeClipboard(shareClipboardText);
|
||||
setButtonState(copySummaryButton, 'fas fa-check', successLabel);
|
||||
setStatus(successLabel);
|
||||
showToast(successLabel, 'success');
|
||||
} catch {
|
||||
setButtonState(copySummaryButton, 'fas fa-triangle-exclamation', failedLabel);
|
||||
setStatus(failedLabel);
|
||||
showToast(failedLabel, 'error');
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
resetButton(copySummaryButton, 'fas fa-copy');
|
||||
}, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyLink(button) {
|
||||
const successLabel = button?.getAttribute('data-success-label') || '';
|
||||
const failedLabel = button?.getAttribute('data-failed-label') || '';
|
||||
|
||||
try {
|
||||
await writeClipboard(canonicalUrl);
|
||||
setButtonState(button, 'fas fa-check', successLabel);
|
||||
setStatus(successLabel);
|
||||
showToast(successLabel, 'success');
|
||||
} catch {
|
||||
setButtonState(button, 'fas fa-triangle-exclamation', failedLabel);
|
||||
setStatus(failedLabel);
|
||||
showToast(failedLabel, 'error');
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
resetButton(button, 'fas fa-link');
|
||||
}, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShareSummary() {
|
||||
const successLabel = shareSummaryButton?.getAttribute('data-success-label') || '';
|
||||
const fallbackLabel = shareSummaryButton?.getAttribute('data-fallback-label') || '';
|
||||
const failedLabel = shareSummaryButton?.getAttribute('data-failed-label') || '';
|
||||
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({
|
||||
title: shareTitle,
|
||||
text: shareSummaryText,
|
||||
url: canonicalUrl,
|
||||
});
|
||||
setButtonState(shareSummaryButton, 'fas fa-check', successLabel);
|
||||
setStatus(successLabel);
|
||||
showToast(successLabel, 'success');
|
||||
} else {
|
||||
await writeClipboard(shareSummaryText);
|
||||
setButtonState(shareSummaryButton, 'fas fa-copy', fallbackLabel);
|
||||
setStatus(fallbackLabel);
|
||||
showToast(fallbackLabel, 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
resetButton(shareSummaryButton, 'fas fa-share-nodes');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonState(shareSummaryButton, 'fas fa-triangle-exclamation', failedLabel);
|
||||
setStatus(failedLabel);
|
||||
showToast(failedLabel, 'error');
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
resetButton(shareSummaryButton, 'fas fa-share-nodes');
|
||||
}, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
function openWechatModal() {
|
||||
if (!wechatModal) return;
|
||||
wechatModal.classList.remove('hidden');
|
||||
wechatModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
setStatus(qrOpenedLabel);
|
||||
showToast(qrOpenedLabel, 'info');
|
||||
}
|
||||
|
||||
function closeWechatModal() {
|
||||
if (!wechatModal) return;
|
||||
wechatModal.classList.add('hidden');
|
||||
wechatModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
copySummaryButton?.addEventListener('click', async () => {
|
||||
await handleCopySummary();
|
||||
});
|
||||
|
||||
shareSummaryButton?.addEventListener('click', async () => {
|
||||
await handleShareSummary();
|
||||
});
|
||||
|
||||
copyLinkButtons.forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
await handleCopyLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
shareLinks.forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
const label = link.textContent?.trim() || '';
|
||||
if (!label) return;
|
||||
setStatus(label);
|
||||
showToast(label, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
wechatOpenButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
openWechatModal();
|
||||
});
|
||||
});
|
||||
|
||||
wechatCloseButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
closeWechatModal();
|
||||
});
|
||||
});
|
||||
|
||||
qrDownloadButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
setStatus(qrDownloadStartedLabel);
|
||||
showToast(qrDownloadStartedLabel, 'info');
|
||||
});
|
||||
});
|
||||
|
||||
wechatModal?.addEventListener('click', (event) => {
|
||||
if (event.target === wechatModal) {
|
||||
closeWechatModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && wechatModal && !wechatModal.classList.contains('hidden')) {
|
||||
closeWechatModal();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
3
frontend/src/env.d.ts
vendored
3
frontend/src/env.d.ts
vendored
@@ -17,9 +17,6 @@ declare global {
|
||||
locale: string;
|
||||
messages: Record<string, unknown>;
|
||||
};
|
||||
__termiCommentsReady?: boolean;
|
||||
__termiHomeReady?: boolean;
|
||||
__termiSubscriptionPopupReady?: boolean;
|
||||
__termiTranslate: (
|
||||
key: string,
|
||||
params?: Record<string, string | number | null | undefined>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user