35 Commits

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

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

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

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

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

feat: implement maintenance mode functionality with access control

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

chore: add TypeScript declaration for QR code module
2026-04-02 23:05:49 +08:00
6a50dd478c feat: refactor page navigation in frontend tests to use gotoPage function
Some checks failed
ui-regression / playwright-regression (push) Failing after 6m54s
2026-04-02 15:36:38 +08:00
ebfb9c7838 feat: enhance build process and add readiness checks for components
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m44s
docker-images / build-and-push (admin) (push) Successful in 1m13s
docker-images / build-and-push (backend) (push) Successful in 45m36s
docker-images / build-and-push (frontend) (push) Successful in 1m29s
docker-images / submit-indexnow (push) Successful in 18s
2026-04-02 14:57:01 +08:00
3628a46ed1 feat: add SharePanel component for social sharing with QR code support
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m47s
docker-images / build-and-push (push) Failing after 7s
docker-images / submit-indexnow (push) Has been skipped
- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms.
- Integrated QR code generation for WeChat sharing using the `qrcode` library.
- Added localization support for English and Chinese languages.
- Created utility functions in `seo.ts` for building article summaries and FAQs.
- Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries.
- Enhanced SEO capabilities with structured data for articles and pages.
2026-04-02 14:15:21 +08:00
a516be2e91 feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s
2026-04-02 03:43:37 +08:00
ee0bec4a78 test: add full playwright ui regression coverage
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 52s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 32s
ui-regression / playwright-regression (push) Failing after 14m24s
2026-04-02 00:55:34 +08:00
7de4ddc3ee feat: refresh content workflow and verification settings
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s
2026-04-01 18:47:17 +08:00
f2c07df320 build: slim backend release profile safely
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 12s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 26m8s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 56s
2026-04-01 13:46:11 +08:00
09d7cbfbf3 ci: allow optional Docker Hub login for buildx pulls
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 54s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
2026-04-01 13:24:26 +08:00
497a9d713d feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
2026-04-01 13:22:19 +08:00
669b79cc95 Fix backend build toolchain and Debian base
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 1m5s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 30m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 1m17s
2026-04-01 02:02:52 +08:00
a305817b78 Stabilize docker workflow on constrained runner
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 1m41s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 27m6s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 1m13s
2026-04-01 00:26:25 +08:00
81fd785d60 Fix docker build syntax frontend for actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 26s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 31s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 25s
2026-04-01 00:22:58 +08:00
660b255700 Fix admin login and add subscription popup settings
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 6s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 6s
2026-04-01 00:05:16 +08:00
350262c910 fix: try multiple registry usernames in docker workflow
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 8s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 4s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 5s
2026-03-31 22:09:02 +08:00
ef2010cb48 fix: allow docker workflow to fallback to gitea token
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 4s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 4s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 4s
2026-03-31 22:06:11 +08:00
43eaaf3602 merge: integrate blog platform admin and deploy stack
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 4s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 5s
2026-03-31 21:53:06 +08:00
313f174fbc feat: ship blog platform admin and deploy stack 2026-03-31 21:48:39 +08:00
a9a05aa105 perf: aggregate homepage data and trim frontend loading 2026-03-31 00:25:58 +08:00
99b308e800 chore: checkpoint admin editor and perf work 2026-03-31 00:12:02 +08:00
341 changed files with 64753 additions and 12280 deletions

View File

@@ -0,0 +1,465 @@
name: docker-images
on:
push:
branches:
- main
- master
paths:
- backend/**
- frontend/**
- admin/**
- deploy/docker/**
- .gitea/workflows/backend-docker.yml
workflow_dispatch:
permissions:
contents: read
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
max-parallel: 1
matrix:
include:
- component: backend
dockerfile: backend/Dockerfile
context: backend
default_image_name: termi-astro-backend
- component: frontend
dockerfile: frontend/Dockerfile
context: frontend
default_image_name: termi-astro-frontend
- component: admin
dockerfile: admin/Dockerfile
context: admin
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:
COMPONENT: ${{ matrix.component }}
DEFAULT_IMAGE_NAME: ${{ matrix.default_image_name }}
VAR_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
VAR_IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE }}
VAR_IMAGE_NAME: ${{ vars.IMAGE_NAME }}
VAR_BACKEND_IMAGE_NAME: ${{ vars.BACKEND_IMAGE_NAME }}
VAR_FRONTEND_IMAGE_NAME: ${{ vars.FRONTEND_IMAGE_NAME }}
VAR_ADMIN_IMAGE_NAME: ${{ vars.ADMIN_IMAGE_NAME }}
VAR_FRONTEND_PUBLIC_API_BASE_URL: ${{ vars.FRONTEND_PUBLIC_API_BASE_URL }}
VAR_ADMIN_VITE_API_BASE: ${{ vars.ADMIN_VITE_API_BASE }}
VAR_ADMIN_VITE_FRONTEND_BASE_URL: ${{ vars.ADMIN_VITE_FRONTEND_BASE_URL }}
VAR_ADMIN_VITE_BASENAME: ${{ vars.ADMIN_VITE_BASENAME }}
run: |
set -euo pipefail
REGISTRY_HOST="${VAR_REGISTRY_HOST:-${GITEA_SERVER_URL#https://}}"
if [ -z "${REGISTRY_HOST}" ]; then
REGISTRY_HOST="git.init.cool"
fi
REPO_OWNER="${GITHUB_REPOSITORY_OWNER:-${GITEA_REPOSITORY_OWNER:-cool}}"
IMAGE_NAMESPACE="${VAR_IMAGE_NAMESPACE:-${REPO_OWNER}}"
case "${COMPONENT}" in
backend)
IMAGE_NAME="${VAR_BACKEND_IMAGE_NAME:-${VAR_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}}"
;;
frontend)
IMAGE_NAME="${VAR_FRONTEND_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}"
;;
admin)
IMAGE_NAME="${VAR_ADMIN_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}"
;;
*)
IMAGE_NAME="${DEFAULT_IMAGE_NAME}"
;;
esac
REF_NAME="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-main}}"
SAFE_REF="$(echo "${REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g')"
COMMIT_SHA="${GITHUB_SHA:-${GITEA_SHA:-dev}}"
SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)"
IMAGE_BASE="${REGISTRY_HOST}/${IMAGE_NAMESPACE}/${IMAGE_NAME}"
FRONTEND_PUBLIC_API_BASE_URL="${VAR_FRONTEND_PUBLIC_API_BASE_URL:-http://localhost:5150/api}"
ADMIN_VITE_API_BASE="${VAR_ADMIN_VITE_API_BASE:-http://localhost:5150}"
ADMIN_VITE_FRONTEND_BASE_URL="${VAR_ADMIN_VITE_FRONTEND_BASE_URL:-http://localhost:4321}"
ADMIN_VITE_BASENAME="${VAR_ADMIN_VITE_BASENAME:-}"
{
echo "registry_host=${REGISTRY_HOST}"
echo "image_base=${IMAGE_BASE}"
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}"
echo "admin_vite_basename=${ADMIN_VITE_BASENAME}"
} >> "$GITHUB_OUTPUT"
- name: Login registry
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }}
REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
BUILTIN_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITHUB_ACTOR_NAME: ${{ github.actor }}
GITHUB_REPOSITORY_OWNER_NAME: ${{ github.repository_owner }}
run: |
set -euo pipefail
CUSTOM_REGISTRY_USER="${REGISTRY_USER:-}"
CUSTOM_REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
BUILTIN_REGISTRY_TOKEN="${BUILTIN_GITEA_TOKEN:-}"
ACTOR_USER="${GITHUB_ACTOR_NAME:-}"
OWNER_USER="${GITHUB_REPOSITORY_OWNER_NAME:-}"
if [ -n "${CUSTOM_REGISTRY_TOKEN}" ]; then
REGISTRY_TOKEN="${CUSTOM_REGISTRY_TOKEN}"
else
REGISTRY_TOKEN="${BUILTIN_REGISTRY_TOKEN}"
fi
if [ -z "${REGISTRY_TOKEN}" ]; then
echo "Missing registry credentials: set REGISTRY_USERNAME/REGISTRY_TOKEN, or rely on the built-in GITEA_TOKEN with packages:write permission."
exit 1
fi
CANDIDATE_USERS=()
for candidate in "${CUSTOM_REGISTRY_USER}" "${ACTOR_USER}" "${OWNER_USER}"; do
if [ -n "${candidate}" ] && [[ ! " ${CANDIDATE_USERS[*]} " =~ [[:space:]]${candidate}[[:space:]] ]]; then
CANDIDATE_USERS+=("${candidate}")
fi
done
if [ ${#CANDIDATE_USERS[@]} -eq 0 ]; then
echo "Missing registry username: set REGISTRY_USERNAME or ensure github.actor/repository_owner are available."
exit 1
fi
LOGIN_OK=0
for candidate in "${CANDIDATE_USERS[@]}"; do
if echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" --username "${candidate}" --password-stdin; then
LOGIN_OK=1
break
fi
done
if [ "${LOGIN_OK}" -ne 1 ]; then
echo "Registry login failed for all candidate usernames."
exit 1
fi
- name: Setup docker buildx
if: steps.should_build.outputs.should_build == 'true'
shell: bash
run: |
set -euo pipefail
if docker buildx inspect gitea-builder >/dev/null 2>&1; then
docker buildx use gitea-builder
else
docker buildx create --name gitea-builder --driver docker-container --use
fi
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 }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
set -euo pipefail
if [ -n "${DOCKERHUB_USERNAME:-}" ] && [ -n "${DOCKERHUB_TOKEN:-}" ]; then
echo "${DOCKERHUB_TOKEN}" | docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin
else
echo "Docker Hub credentials not configured, continuing with anonymous pulls."
fi
- name: Build and push image
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
COMPONENT: ${{ matrix.component }}
DOCKERFILE: ${{ matrix.dockerfile }}
CONTEXT_DIR: ${{ matrix.context }}
IMAGE_BASE: ${{ steps.meta.outputs.image_base }}
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 }}
ADMIN_VITE_BASENAME: ${{ steps.meta.outputs.admin_vite_basename }}
run: |
set -euo pipefail
BUILD_ARGS=()
if [ "${COMPONENT}" = "frontend" ]; then
BUILD_ARGS+=(--build-arg "PUBLIC_API_BASE_URL=${FRONTEND_PUBLIC_API_BASE_URL}")
fi
if [ "${COMPONENT}" = "admin" ]; then
BUILD_ARGS+=(--build-arg "VITE_API_BASE=${ADMIN_VITE_API_BASE}")
BUILD_ARGS+=(--build-arg "VITE_FRONTEND_BASE_URL=${ADMIN_VITE_FRONTEND_BASE_URL}")
BUILD_ARGS+=(--build-arg "VITE_ADMIN_BASENAME=${ADMIN_VITE_BASENAME}")
fi
docker buildx build \
--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}" \
--tag "${IMAGE_BASE}:${TAG_SHA}" \
--push \
"${CONTEXT_DIR}"
- name: Output image tags
if: steps.should_build.outputs.should_build == 'true'
shell: bash
env:
COMPONENT: ${{ matrix.component }}
IMAGE_BASE: ${{ steps.meta.outputs.image_base }}
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
run: |
echo "[${COMPONENT}] pushed tags:"
echo "- ${IMAGE_BASE}:${TAG_LATEST}"
echo "- ${IMAGE_BASE}:${TAG_BRANCH}"
echo "- ${IMAGE_BASE}:${TAG_SHA}"
submit-indexnow:
needs:
- resolve-build-targets
- build-and-push
if: needs.resolve-build-targets.outputs.frontend_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Submit IndexNow (optional)
shell: bash
env:
INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
SITE_URL: ${{ vars.INDEXNOW_SITE_URL }}
PUBLIC_API_BASE_URL: ${{ vars.INDEXNOW_PUBLIC_API_BASE_URL }}
GITHUB_REF_NAME_VALUE: ${{ github.ref_name }}
run: |
set -euo pipefail
REF_NAME="${GITHUB_REF_NAME_VALUE:-${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}}"
if [ "${GITHUB_EVENT_NAME:-${GITEA_EVENT_NAME:-}}" != "push" ]; then
echo "Current event is not push, skip IndexNow submission."
exit 0
fi
if [ "${REF_NAME}" != "main" ] && [ "${REF_NAME}" != "master" ]; then
echo "Current ref '${REF_NAME}' is not main/master, skip IndexNow submission."
exit 0
fi
if [ -z "${INDEXNOW_KEY:-}" ]; then
echo "Missing INDEXNOW_KEY secret, skip IndexNow submission."
exit 0
fi
if [ -z "${SITE_URL:-}" ]; then
echo "Missing INDEXNOW_SITE_URL variable, skip IndexNow submission."
exit 0
fi
pnpm --dir frontend run indexnow:submit

View File

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

26
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.codex/
.codex-tmp/
.playwright-mcp/
.vscode/
.windsurf/
@@ -13,6 +15,30 @@ backend/target/
backend/.loco-start.err.log
backend/.loco-start.out.log
backend/backend-start.log
backend/*.log
backend/*.err.log
backend/*.out.log
backend/storage/ai_embedding_models/
backend-start.err
backend-start.log
# local deployment/runtime artifacts
deploy/docker/.env
deploy/docker/config.yaml
admin/tmp-playwright.*
admin/.vite/
test-results/
playwright-report/
blob-report/
*-playwright.err.log
*-playwright.out.log
backend-restart.err.log
backend-restart.out.log
frontend.dev.err.log
frontend.dev.out.log
admin.dev.err.log
admin.dev.out.log
backend.dev.err.log
backend.dev.out.log
lighthouse-*/
lighthouse-*.json

105
README.md
View File

@@ -8,8 +8,9 @@ Monorepo for the Termi blog system.
.
├─ admin/ # React + shadcn admin workspace
├─ frontend/ # Astro blog frontend
├─ backend/ # Loco.rs backend APIs and legacy Tera admin
├─ backend/ # Loco.rs backend APIs
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
├─ deploy/ # Deployment manifests (docker compose/env examples)
├─ .codex/ # Codex workspace config
└─ .vscode/ # Editor workspace config
```
@@ -21,7 +22,7 @@ Monorepo for the Termi blog system.
From the repository root:
```powershell
npm run dev
pnpm dev
```
This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs.
@@ -29,13 +30,14 @@ This starts `frontend + admin + backend` in a single Windows Terminal window wit
Common shortcuts:
```powershell
npm run dev:mcp
npm run dev:frontend
npm run dev:admin
npm run dev:backend
npm run dev:mcp-only
npm run stop
npm run restart
pnpm dev:mcp
pnpm dev:frontend
pnpm dev:admin
pnpm dev:backend
pnpm dev:mcp-only
pnpm stop
pnpm restart
pnpm test:ui
```
### PowerShell entrypoint
@@ -70,16 +72,16 @@ Legacy aliases are still available and now just forward to `dev.ps1`:
```powershell
cd frontend
npm install
npm run dev
pnpm install
pnpm dev
```
### Admin
```powershell
cd admin
npm install
npm run dev
pnpm install
pnpm dev
```
### Backend
@@ -87,9 +89,84 @@ npm run dev
```powershell
cd backend
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
cargo loco start 2>&1
cargo loco start --server-and-worker 2>&1
```
如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`
### Docker生产部署使用 Gitea Package 镜像)
补充部署分层与反代说明见:
- `deploy/docker/ARCHITECTURE.md`
- `deploy/caddy/Caddyfile.tohka.example`
```powershell
docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env up -d
```
当前 compose 默认启动:
- frontend: `http://127.0.0.1:4321`
- admin: `http://127.0.0.1:4322`
- backend api: `http://127.0.0.1:5150`
> 注意:`deploy/docker/compose.package.yml` 不内置 postgres/redis需使用外部数据库与 Redis。
如果你不是直接用默认端口直连,而是走独立域名 / HTTPS / 反向代理,建议同时设置这些 compose 运行时变量:
- `INTERNAL_API_BASE_URL=http://backend:5150/api`
- `PUBLIC_API_BASE_URL=https://api.blog.init.cool`
- `PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev`
- `ADMIN_API_BASE_URL=https://admin.blog.init.cool`
- `ADMIN_FRONTEND_BASE_URL=https://blog.init.cool`
可复制 `deploy/docker/.env.example``deploy/docker/.env` 后,至少设置:
- `DATABASE_URL`
- `REDIS_URL`
- `JWT_SECRET`
如需覆盖镜像 tag
```powershell
$env:BACKEND_IMAGE="git.init.cool/<owner>/termi-astro-backend:latest"
$env:FRONTEND_IMAGE="git.init.cool/<owner>/termi-astro-frontend:latest"
$env:ADMIN_IMAGE="git.init.cool/<owner>/termi-astro-admin:latest"
docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.env up -d
```
### Gitea Actions Docker 发布
仓库已新增:`.gitea/workflows/backend-docker.yml`
需要在仓库里配置:
- Secrets
- `REGISTRY_USERNAME`
- `REGISTRY_TOKEN`
- `INDEXNOW_KEY`(可选;如果要在主分支镜像发布后自动提交 IndexNow
- Variables可选
- `REGISTRY_HOST`(默认 `git.init.cool`
- `IMAGE_NAMESPACE`(默认仓库 owner
- `BACKEND_IMAGE_NAME`(默认 `termi-astro-backend`
- `FRONTEND_IMAGE_NAME`(默认 `termi-astro-frontend`
- `ADMIN_IMAGE_NAME`(默认 `termi-astro-admin`
- `FRONTEND_PUBLIC_API_BASE_URL`frontend 镜像构建注入的浏览器侧 API 默认地址,默认 `http://localhost:5150/api`;运行时推荐优先使用 `PUBLIC_API_BASE_URL`
- `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
```powershell

5
admin/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.log

29
admin/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:22-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_BASE=http://localhost:5150
ARG VITE_FRONTEND_BASE_URL=http://localhost:4321
ARG VITE_ADMIN_BASENAME=
ENV VITE_API_BASE=${VITE_API_BASE}
ENV VITE_FRONTEND_BASE_URL=${VITE_FRONTEND_BASE_URL}
ENV VITE_ADMIN_BASENAME=${VITE_ADMIN_BASENAME}
RUN pnpm build
FROM nginx:1.27-alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
COPY docker-entrypoint.d/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
RUNTIME_CONFIG_FILE="/usr/share/nginx/html/runtime-config.js"
escape_js_string() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
API_BASE_URL="${ADMIN_API_BASE_URL:-}"
FRONTEND_BASE_URL="${ADMIN_FRONTEND_BASE_URL:-}"
ESCAPED_API_BASE_URL="$(escape_js_string "$API_BASE_URL")"
ESCAPED_FRONTEND_BASE_URL="$(escape_js_string "$FRONTEND_BASE_URL")"
cat > "$RUNTIME_CONFIG_FILE" <<EOF
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = Object.assign(
{},
window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {},
{
apiBaseUrl: "${ESCAPED_API_BASE_URL}",
frontendBaseUrl: "${ESCAPED_FRONTEND_BASE_URL}"
},
)
EOF

View File

@@ -18,6 +18,7 @@
</head>
<body>
<div id="root"></div>
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

63
admin/nginx.conf Normal file
View File

@@ -0,0 +1,63 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
charset utf-8;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
application/manifest+json
image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
add_header Cache-Control "no-store";
return 200 'ok';
}
location = /runtime-config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

2304
admin/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {}

View File

@@ -1,5 +1,7 @@
import {
createContext,
lazy,
Suspense,
startTransition,
useContext,
useEffect,
@@ -22,13 +24,80 @@ import { Toaster, toast } from 'sonner'
import { AppShell } from '@/components/app-shell'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminSessionResponse } from '@/lib/types'
import { CommentsPage } from '@/pages/comments-page'
import { DashboardPage } from '@/pages/dashboard-page'
import { FriendLinksPage } from '@/pages/friend-links-page'
import { LoginPage } from '@/pages/login-page'
import { PostsPage } from '@/pages/posts-page'
import { ReviewsPage } from '@/pages/reviews-page'
import { SiteSettingsPage } from '@/pages/site-settings-page'
const DashboardPage = lazy(async () => {
const mod = await import('@/pages/dashboard-page')
return { default: mod.DashboardPage }
})
const AnalyticsPage = lazy(async () => {
const mod = await import('@/pages/analytics-page')
return { default: mod.AnalyticsPage }
})
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 }
})
const TagsPage = lazy(async () => {
const mod = await import('@/pages/tags-page')
return { default: mod.TagsPage }
})
const BackupsPage = lazy(async () => {
const mod = await import('@/pages/backups-page')
return { default: mod.BackupsPage }
})
const RevisionsPage = lazy(async () => {
const mod = await import('@/pages/revisions-page')
return { default: mod.RevisionsPage }
})
const CommentsPage = lazy(async () => {
const mod = await import('@/pages/comments-page')
return { default: mod.CommentsPage }
})
const FriendLinksPage = lazy(async () => {
const mod = await import('@/pages/friend-links-page')
return { default: mod.FriendLinksPage }
})
const MediaPage = lazy(async () => {
const mod = await import('@/pages/media-page')
return { default: mod.MediaPage }
})
const ReviewsPage = lazy(async () => {
const mod = await import('@/pages/reviews-page')
return { default: mod.ReviewsPage }
})
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 }
})
type SessionContextValue = {
session: AdminSessionResponse
@@ -69,6 +138,26 @@ function AppLoadingScreen() {
)
}
function RouteLoadingScreen() {
return (
<div className="flex min-h-[320px] items-center justify-center rounded-3xl border border-border/70 bg-card/60 px-6 py-10 text-center text-muted-foreground">
<div className="space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LoaderCircle className="h-5 w-5 animate-spin" />
</div>
<div>
<p className="text-sm font-medium text-foreground"></p>
<p className="mt-1 text-sm"></p>
</div>
</div>
</div>
)
}
function LazyRoute({ children }: { children: ReactNode }) {
return <Suspense fallback={<RouteLoadingScreen />}>{children}</Suspense>
}
function RequireAuth({ children }: { children: ReactNode }) {
const { session } = useSession()
@@ -91,6 +180,8 @@ function PublicOnly() {
return (
<LoginPage
submitting={submitting}
localLoginEnabled={session.local_login_enabled}
proxyAuthEnabled={session.proxy_auth_enabled}
onLogin={async (payload) => {
try {
setSubmitting(true)
@@ -118,7 +209,11 @@ function ProtectedLayout() {
return (
<AppShell
username={session.username}
email={session.email}
authSource={session.auth_source}
authProvider={session.auth_provider}
loggingOut={loggingOut}
canLogout={session.can_logout}
onLogout={async () => {
try {
setLoggingOut(true)
@@ -144,6 +239,56 @@ function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<PublicOnly />} />
<Route
path="/posts/preview"
element={
<RequireAuth>
<LazyRoute>
<PostPreviewPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/:slug/preview"
element={
<RequireAuth>
<LazyRoute>
<PostPreviewPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/compare"
element={
<RequireAuth>
<LazyRoute>
<PostComparePage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/:slug/compare"
element={
<RequireAuth>
<LazyRoute>
<PostComparePage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/posts/polish"
element={
<RequireAuth>
<LazyRoute>
<PostPolishPage />
</LazyRoute>
</RequireAuth>
}
/>
<Route
path="/"
element={
@@ -151,14 +296,135 @@ function AppRoutes() {
<ProtectedLayout />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} />
<Route path="posts" element={<PostsPage />} />
<Route path="posts/:slug" element={<PostsPage />} />
<Route path="comments" element={<CommentsPage />} />
<Route path="friend-links" element={<FriendLinksPage />} />
<Route path="reviews" element={<ReviewsPage />} />
<Route path="settings" element={<SiteSettingsPage />} />
>
<Route
index
element={
<LazyRoute>
<DashboardPage />
</LazyRoute>
}
/>
<Route
path="analytics"
element={
<LazyRoute>
<AnalyticsPage />
</LazyRoute>
}
/>
<Route
path="posts"
element={
<LazyRoute>
<PostsPage />
</LazyRoute>
}
/>
<Route
path="posts/:slug"
element={
<LazyRoute>
<PostsPage />
</LazyRoute>
}
/>
<Route
path="categories"
element={
<LazyRoute>
<CategoriesPage />
</LazyRoute>
}
/>
<Route
path="tags"
element={
<LazyRoute>
<TagsPage />
</LazyRoute>
}
/>
<Route
path="backups"
element={
<LazyRoute>
<BackupsPage />
</LazyRoute>
}
/>
<Route
path="revisions"
element={
<LazyRoute>
<RevisionsPage />
</LazyRoute>
}
/>
<Route
path="comments"
element={
<LazyRoute>
<CommentsPage />
</LazyRoute>
}
/>
<Route
path="friend-links"
element={
<LazyRoute>
<FriendLinksPage />
</LazyRoute>
}
/>
<Route
path="media"
element={
<LazyRoute>
<MediaPage />
</LazyRoute>
}
/>
<Route
path="subscriptions"
element={
<LazyRoute>
<SubscriptionsPage />
</LazyRoute>
}
/>
<Route
path="workers"
element={
<LazyRoute>
<WorkersPage />
</LazyRoute>
}
/>
<Route
path="audit"
element={
<LazyRoute>
<AuditPage />
</LazyRoute>
}
/>
<Route
path="reviews"
element={
<LazyRoute>
<ReviewsPage />
</LazyRoute>
}
/>
<Route
path="settings"
element={
<LazyRoute>
<SiteSettingsPage />
</LazyRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -169,6 +435,13 @@ export default function App() {
const [session, setSession] = useState<AdminSessionResponse>({
authenticated: false,
username: null,
email: null,
auth_source: null,
auth_provider: null,
groups: [],
proxy_auth_enabled: false,
local_login_enabled: true,
can_logout: false,
})
const [loading, setLoading] = useState(true)

View File

@@ -1,6 +1,12 @@
import {
BarChart3,
BellRing,
BookOpenText,
Download,
ExternalLink,
Folders,
History,
Image as ImageIcon,
LayoutDashboard,
Link2,
LogOut,
@@ -9,6 +15,8 @@ import {
ScrollText,
Settings,
Sparkles,
Tags,
Workflow,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
@@ -16,6 +24,7 @@ import { NavLink } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
const primaryNav = [
@@ -25,12 +34,42 @@ const primaryNav = [
description: '站点运营总览',
icon: LayoutDashboard,
},
{
to: '/analytics',
label: '数据分析',
description: '搜索词与 AI 问答洞察',
icon: BarChart3,
},
{
to: '/posts',
label: '文章',
description: 'Markdown 内容管理',
icon: ScrollText,
},
{
to: '/categories',
label: '分类',
description: '分类目录与聚合统计',
icon: Folders,
},
{
to: '/tags',
label: '标签',
description: '标签库与引用整理',
icon: Tags,
},
{
to: '/backups',
label: '备份',
description: '全站导出与恢复',
icon: Download,
},
{
to: '/revisions',
label: '版本',
description: '历史快照与一键回滚',
icon: History,
},
{
to: '/comments',
label: '评论',
@@ -49,6 +88,30 @@ const primaryNav = [
description: '评测内容库',
icon: BookOpenText,
},
{
to: '/media',
label: '媒体库',
description: '对象存储图片管理',
icon: ImageIcon,
},
{
to: '/subscriptions',
label: '订阅',
description: '邮件 / Webhook 推送',
icon: BellRing,
},
{
to: '/workers',
label: 'Workers',
description: '异步任务 / 队列控制台',
icon: Workflow,
},
{
to: '/audit',
label: '审计',
description: '后台操作日志与排障线索',
icon: History,
},
{
to: '/settings',
label: '设置',
@@ -60,12 +123,20 @@ const primaryNav = [
export function AppShell({
children,
username,
email,
authSource,
authProvider,
loggingOut,
canLogout,
onLogout,
}: {
children: ReactNode
username: string | null
email: string | null
authSource: string | null
authProvider: string | null
loggingOut: boolean
canLogout: boolean
onLogout: () => Promise<void>
}) {
return (
@@ -141,7 +212,7 @@ export function AppShell({
</p>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Badge variant="success"></Badge>
@@ -172,8 +243,13 @@ export function AppShell({
{username ?? 'admin'}
</p>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
React + shadcn/ui
{authProvider ?? 'React + shadcn/ui 基础架构'}
</p>
{email ? (
<p className="text-xs text-muted-foreground">{email}</p>
) : authSource ? (
<p className="text-xs text-muted-foreground">{authSource}</p>
) : null}
</div>
</div>
@@ -199,14 +275,19 @@ export function AppShell({
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
<a href={buildFrontendUrl('/')} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button variant="ghost" onClick={() => void onLogout()} disabled={loggingOut}>
<Button
variant="ghost"
onClick={() => void onLogout()}
disabled={loggingOut || !canLogout}
title={canLogout ? undefined : '当前会话由前置 SSO / 代理控制'}
>
<LogOut className="h-4 w-4" />
{loggingOut ? '退出中...' : '退出登录'}
{canLogout ? (loggingOut ? '退出中...' : '退出登录') : 'SSO 受代理保护'}
</Button>
</div>
</div>

View File

@@ -0,0 +1,72 @@
import { lazy, Suspense } from 'react'
import type { DiffEditorProps, EditorProps } from '@monaco-editor/react'
const MonacoEditor = lazy(async () => {
const mod = await import('@monaco-editor/react')
return { default: mod.default }
})
const MonacoDiffEditor = lazy(async () => {
const mod = await import('@monaco-editor/react')
return { default: mod.DiffEditor }
})
function MonacoLoading({
height,
width,
className,
loading,
}: {
height?: string | number
width?: string | number
className?: string
loading?: React.ReactNode
}) {
return (
<div
className={className}
style={{ height: height ?? '100%', width: width ?? '100%' }}
>
{loading ?? (
<div className="flex h-full min-h-[280px] items-center justify-center bg-[#111111] text-sm text-slate-400">
...
</div>
)}
</div>
)
}
export function LazyEditor(props: EditorProps) {
return (
<Suspense
fallback={
<MonacoLoading
height={props.height}
width={props.width}
className={props.className}
loading={props.loading}
/>
}
>
<MonacoEditor {...props} />
</Suspense>
)
}
export function LazyDiffEditor(props: DiffEditorProps) {
return (
<Suspense
fallback={
<MonacoLoading
height={props.height}
width={props.width}
className={props.className}
loading={props.loading}
/>
}
>
<MonacoDiffEditor {...props} />
</Suspense>
)
}

View File

@@ -1,6 +1,6 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { useMemo } from 'react'
import { useDeferredValue, useMemo } from 'react'
import { cn } from '@/lib/utils'
@@ -15,10 +15,11 @@ marked.setOptions({
})
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
const deferredMarkdown = useDeferredValue(markdown)
const html = useMemo(() => {
const rendered = marked.parse(markdown || '暂无内容。')
const rendered = marked.parse(deferredMarkdown || '暂无内容。')
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
}, [markdown])
}, [deferredMarkdown])
return (
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>

View File

@@ -2,9 +2,10 @@ import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import Editor, { DiffEditor, type BeforeMount } from '@monaco-editor/react'
import type { BeforeMount } from '@monaco-editor/react'
import { Expand, Minimize2, Sparkles } from 'lucide-react'
import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -16,6 +17,7 @@ type MarkdownWorkbenchProps = {
originalValue: string
diffValue?: string
path: string
workspaceHeightClassName?: string
readOnly?: boolean
mode: MarkdownWorkbenchMode
visiblePanels: MarkdownWorkbenchPanel[]
@@ -114,6 +116,7 @@ export function MarkdownWorkbench({
originalValue,
diffValue,
path,
workspaceHeightClassName = 'h-[560px]',
readOnly = false,
mode,
visiblePanels,
@@ -128,7 +131,7 @@ export function MarkdownWorkbench({
onVisiblePanelsChange,
}: MarkdownWorkbenchProps) {
const [fullscreen, setFullscreen] = useState(false)
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : 'h-[560px]'
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName
const diffContent = diffValue ?? value
const polishEnabled = allowPolish ?? Boolean(polishPanel)
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
@@ -176,7 +179,7 @@ export function MarkdownWorkbench({
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
</div>
<p className="font-mono text-xs text-slate-400">{path}</p>
<p className="font-mono text-xs text-slate-400">Markdown </p>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -255,14 +258,12 @@ export function MarkdownWorkbench({
<span>
{originalLabel} / {modifiedLabel}
</span>
) : (
<span>{path}</span>
)}
) : null}
</div>
{panel === 'edit' ? (
<div className="min-h-0 flex-1">
<Editor
<LazyEditor
height="100%"
language="markdown"
path={path}
@@ -286,7 +287,7 @@ export function MarkdownWorkbench({
{panel === 'diff' ? (
<div className="min-h-0 flex-1">
<DiffEditor
<LazyDiffEditor
height="100%"
language="markdown"
original={originalValue}

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,514 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = React.forwardRef<HTMLSelectElement, React.ComponentProps<'select'>>(
({ className, ...props }, ref) => (
<select
ref={ref}
className={cn(
'flex h-11 w-full rounded-xl border border-input bg-background/80 px-3 py-2 text-sm shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
type NativeSelectProps = React.ComponentProps<'select'> & {
'data-testid'?: string
}
type SelectOption = {
value: string
label: React.ReactNode
disabled: boolean
}
type MenuPlacement = 'top' | 'bottom'
function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) {
if (Array.isArray(value)) {
return value[0] == null ? '' : String(value[0])
}
return value == null ? '' : String(value)
}
function extractOptions(children: React.ReactNode) {
const options: SelectOption[] = []
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child) || child.type !== 'option') {
return
}
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement> & {
children?: React.ReactNode
}
options.push({
value: normalizeValue(props.value),
label: props.children,
disabled: Boolean(props.disabled),
})
})
return options
}
function getFirstEnabledIndex(options: SelectOption[]) {
return options.findIndex((option) => !option.disabled)
}
function getLastEnabledIndex(options: SelectOption[]) {
for (let index = options.length - 1; index >= 0; index -= 1) {
if (!options[index]?.disabled) {
return index
}
}
return -1
}
function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) {
if (options.length === 0) {
return -1
}
let index = currentIndex
for (let step = 0; step < options.length; step += 1) {
index = (index + direction + options.length) % options.length
if (!options[index]?.disabled) {
return index
}
}
return -1
}
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
(
{
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
children,
className,
'data-testid': dataTestId,
defaultValue,
disabled = false,
id,
onBlur,
onClick,
onFocus,
onKeyDown,
value,
...props
},
forwardedRef,
) => {
const options = React.useMemo(() => extractOptions(children), [children])
const isControlled = value !== undefined
const initialValue = React.useMemo(() => {
if (defaultValue !== undefined) {
return normalizeValue(defaultValue)
}
return options[0]?.value ?? ''
}, [defaultValue, options])
const [internalValue, setInternalValue] = React.useState(initialValue)
const [open, setOpen] = React.useState(false)
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
const [menuPlacement, setMenuPlacement] = React.useState<MenuPlacement>('bottom')
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties | null>(null)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
const menuRef = React.useRef<HTMLDivElement>(null)
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
const menuId = React.useId()
const currentValue = isControlled ? normalizeValue(value) : internalValue
const selectedIndex = options.findIndex((option) => option.value === currentValue)
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null
React.useEffect(() => {
if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) {
setInternalValue(options[0]?.value ?? '')
}
}, [internalValue, isControlled, options])
const updateMenuPosition = React.useCallback(() => {
const trigger = triggerRef.current
if (!trigger) {
return
}
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 left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
setMenuPlacement(openToTop ? 'top' : 'bottom')
setMenuStyle(
openToTop
? {
left,
width,
maxHeight,
bottom: window.innerHeight - rect.top + gutter,
}
: {
left,
width,
maxHeight,
top: rect.bottom + gutter,
},
)
}, [options.length])
const setOpenWithHighlight = React.useCallback(
(nextOpen: boolean, preferredIndex?: number) => {
if (disabled) {
return
}
if (nextOpen) {
const fallbackIndex =
preferredIndex ??
(selectedIndex >= 0 && !options[selectedIndex]?.disabled
? selectedIndex
: getFirstEnabledIndex(options))
setHighlightedIndex(fallbackIndex)
updateMenuPosition()
setOpen(true)
return
}
setOpen(false)
},
[disabled, options, selectedIndex, updateMenuPosition],
)
const commitValue = React.useCallback(
(nextIndex: number) => {
const option = options[nextIndex]
const nativeSelect = nativeSelectRef.current
if (!option || option.disabled) {
return
}
if (!isControlled) {
setInternalValue(option.value)
}
if (nativeSelect && currentValue !== option.value) {
nativeSelect.value = option.value
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }))
}
setOpen(false)
window.requestAnimationFrame(() => {
triggerRef.current?.focus()
})
},
[currentValue, isControlled, options],
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement | HTMLDivElement>) => {
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLSelectElement>)
if (event.defaultPrevented || disabled) {
return
}
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
if (!open) {
const nextIndex =
selectedIndex >= 0
? getNextEnabledIndex(options, selectedIndex, 1)
: getFirstEnabledIndex(options)
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options))
return
}
setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1))
return
}
case 'ArrowUp': {
event.preventDefault()
if (!open) {
const nextIndex =
selectedIndex >= 0
? getNextEnabledIndex(options, selectedIndex, -1)
: getLastEnabledIndex(options)
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options))
return
}
setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1))
return
}
case 'Home': {
event.preventDefault()
const firstIndex = getFirstEnabledIndex(options)
if (!open) {
setOpenWithHighlight(true, firstIndex)
return
}
setHighlightedIndex(firstIndex)
return
}
case 'End': {
event.preventDefault()
const lastIndex = getLastEnabledIndex(options)
if (!open) {
setOpenWithHighlight(true, lastIndex)
return
}
setHighlightedIndex(lastIndex)
return
}
case 'Enter':
case ' ': {
event.preventDefault()
if (!open) {
setOpenWithHighlight(true)
return
}
if (highlightedIndex >= 0) {
commitValue(highlightedIndex)
}
return
}
case 'Escape': {
if (!open) {
return
}
event.preventDefault()
setOpen(false)
return
}
case 'Tab': {
setOpen(false)
return
}
default:
return
}
},
[commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight],
)
React.useLayoutEffect(() => {
if (!open) {
return
}
updateMenuPosition()
}, [open, updateMenuPosition])
React.useEffect(() => {
if (!open) {
return
}
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node
if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) {
return
}
setOpen(false)
}
const handleWindowChange = () => updateMenuPosition()
document.addEventListener('mousedown', handlePointerDown)
window.addEventListener('resize', handleWindowChange)
window.addEventListener('scroll', handleWindowChange, true)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
window.removeEventListener('resize', handleWindowChange)
window.removeEventListener('scroll', handleWindowChange, true)
}
}, [open, updateMenuPosition])
React.useEffect(() => {
if (!open || highlightedIndex < 0) {
return
}
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
}, [highlightedIndex, open])
const triggerClasses = cn(
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/20 data-[state=open]:bg-card/95 data-[state=open]:shadow-[0_16px_36px_rgb(15_23_42_/_0.10)]',
className,
)
const menu = open && menuStyle
? ReactDOM.createPortal(
<div
ref={menuRef}
aria-orientation="vertical"
className={cn(
'custom-select-popover fixed z-[80] overflow-hidden rounded-[20px] border border-border/80 bg-[color:rgb(255_255_255_/_0.96)] p-2 text-popover-foreground shadow-[0_18px_46px_rgb(15_23_42_/_0.12)] backdrop-blur-xl will-change-transform dark:bg-card/96',
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
)}
id={menuId}
onKeyDown={handleKeyDown}
role="listbox"
style={menuStyle}
tabIndex={-1}
>
<div className="max-h-full overflow-y-auto pr-0.5">
{options.map((option, index) => {
const selected = option.value === currentValue
const highlighted = index === highlightedIndex
return (
<button
key={`${option.value}-${index}`}
ref={(node) => {
optionRefs.current[index] = node
}}
aria-selected={selected}
className={cn(
'relative flex min-h-10.5 w-full items-center justify-between gap-3 overflow-hidden rounded-[16px] border px-4 py-2.5 text-left text-sm transition-[background-color,border-color,color,box-shadow]',
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
selected
? 'border-primary/15 bg-primary/[0.045] text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.55)]'
: highlighted
? 'border-border/60 bg-muted/70 text-foreground'
: 'border-transparent text-foreground/80 hover:border-border/45 hover:bg-muted/55 hover:text-foreground',
)}
disabled={option.disabled}
onClick={() => commitValue(index)}
onMouseEnter={() => {
if (!option.disabled) {
setHighlightedIndex(index)
}
}}
role="option"
type="button"
>
<span
aria-hidden="true"
className={cn(
'absolute left-1.5 top-1/2 h-5 w-1 -translate-y-1/2 rounded-full transition-all',
selected ? 'bg-primary/70 opacity-100' : 'bg-transparent opacity-0',
)}
/>
<span
className={cn(
'truncate pr-2',
selected
? 'font-semibold text-foreground'
: highlighted
? 'font-medium text-foreground'
: 'font-medium',
)}
>
{option.label}
</span>
<Check
className={cn(
'h-3.5 w-3.5 shrink-0 transition-[opacity,transform,color]',
selected ? 'translate-x-0 opacity-100 text-primary/90' : 'translate-x-1 opacity-0 text-transparent',
)}
/>
</button>
)
})}
</div>
</div>,
document.body,
)
: null
return (
<div className="relative w-full" ref={wrapperRef}>
<select
{...props}
aria-hidden="true"
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}
ref={(node) => {
nativeSelectRef.current = node
if (typeof forwardedRef === 'function') {
forwardedRef(node)
} else if (forwardedRef) {
forwardedRef.current = node
}
}}
tabIndex={-1}
value={isControlled ? currentValue : internalValue}
>
{children}
</select>
<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) => {
onBlur?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
}}
onClick={(event) => {
onClick?.(event as unknown as React.MouseEvent<HTMLSelectElement>)
}}
onPointerDown={(event) => {
if (event.button !== 0 || disabled) {
return
}
event.preventDefault()
triggerRef.current?.focus()
setOpenWithHighlight(!open)
}}
onFocus={(event) => {
onFocus?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
}}
onKeyDown={handleKeyDown}
ref={triggerRef}
role="combobox"
type="button"
>
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors',
open && 'bg-muted text-foreground',
)}
>
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
</span>
</button>
{menu}
</div>
)
},
)
Select.displayName = 'Select'

View File

@@ -7,8 +7,8 @@
--card-foreground: oklch(0.18 0.02 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.02 255);
--primary: oklch(0.57 0.17 255);
--primary-foreground: oklch(0.98 0.01 255);
--primary: oklch(0.5 0.16 255);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0.02 220);
--secondary-foreground: oklch(0.28 0.03 250);
--muted: oklch(0.95 0.01 250);
@@ -20,7 +20,7 @@
--border: oklch(0.9 0.01 250);
--input: oklch(0.91 0.01 250);
--ring: oklch(0.57 0.17 255);
--success: oklch(0.72 0.16 160);
--success: oklch(0.63 0.14 160);
--warning: oklch(0.81 0.16 78);
--radius: 1.15rem;
}
@@ -116,6 +116,23 @@ a {
button,
input,
textarea {
textarea,
select {
font: inherit;
}
@keyframes custom-select-pop {
from {
opacity: 0;
transform: translateY(-2px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.custom-select-popover {
animation: custom-select-pop 0.1s ease-out;
}

View File

@@ -41,6 +41,76 @@ export function formatCommentScope(value: string | null | undefined) {
}
}
export function formatPostStatus(value: string | null | undefined) {
switch (value) {
case 'draft':
return '草稿'
case 'published':
return '已发布'
case 'scheduled':
return '定时发布'
case 'expired':
return '已下线'
case 'offline':
return '离线'
default:
return value || '已发布'
}
}
export function formatPostVisibility(value: string | null | undefined) {
switch (value) {
case 'unlisted':
return '不公开'
case 'private':
return '私有'
case 'public':
return '公开'
default:
return value || '公开'
}
}
function matchBrowserVersion(userAgent: string, marker: RegExp) {
const matched = userAgent.match(marker)
return matched?.[1] ?? null
}
export function formatBrowserName(userAgent: string | null | undefined) {
if (!userAgent) {
return '未知浏览器'
}
const ua = userAgent.toLowerCase()
if (ua.includes('edg/')) {
const version = matchBrowserVersion(userAgent, /edg\/([\d.]+)/i)
return version ? `Edge ${version}` : 'Edge'
}
if (ua.includes('opr/') || ua.includes('opera')) {
const version = matchBrowserVersion(userAgent, /(?:opr|opera)\/([\d.]+)/i)
return version ? `Opera ${version}` : 'Opera'
}
if (ua.includes('firefox/')) {
const version = matchBrowserVersion(userAgent, /firefox\/([\d.]+)/i)
return version ? `Firefox ${version}` : 'Firefox'
}
if (ua.includes('chrome/') && !ua.includes('chromium/')) {
const version = matchBrowserVersion(userAgent, /chrome\/([\d.]+)/i)
return version ? `Chrome ${version}` : 'Chrome'
}
if (ua.includes('safari/') && !ua.includes('chrome/')) {
const version = matchBrowserVersion(userAgent, /version\/([\d.]+)/i)
return version ? `Safari ${version}` : 'Safari'
}
return '其他浏览器'
}
export function formatFriendLinkStatus(value: string | null | undefined) {
switch (value) {
case 'approved':

View File

@@ -1,12 +1,32 @@
import type {
AdminAiReindexResponse,
AdminAnalyticsResponse,
AdminAiImageProviderTestResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaBatchDeleteResponse,
AdminMediaDownloadResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminMediaMetadataResponse,
AdminMediaReplaceResponse,
AdminMediaUploadResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminPostLocalizeImagesResponse,
AdminDashboardResponse,
AdminPostMetadataResponse,
AdminPostPolishResponse,
AdminReviewPolishRequest,
AdminReviewPolishResponse,
AdminR2ConnectivityResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
AuditLogRecord,
CategoryRecord,
CommentListQuery,
CommentBlacklistRecord,
CommentPersonaAnalysisLogRecord,
CommentPersonaAnalysisResponse,
CommentRecord,
CreatePostPayload,
CreateReviewPayload,
@@ -16,16 +36,65 @@ import type {
MarkdownDeleteResponse,
MarkdownDocumentResponse,
MarkdownImportResponse,
MediaAssetMetadataPayload,
MediaDownloadPayload,
NotificationDeliveryRecord,
PostPageResponse,
PostListQuery,
PostRevisionDetail,
PostRevisionRecord,
PostRecord,
ReviewRecord,
RestoreRevisionResponse,
SiteSettingsPayload,
SiteBackupDocument,
SiteBackupImportPayload,
SiteBackupImportResponse,
SubscriptionDigestResponse,
SubscriptionListResponse,
SubscriptionPayload,
SubscriptionRecord,
SubscriptionUpdatePayload,
WorkerJobListResponse,
WorkerJobRecord,
WorkerOverview,
WorkerTaskActionResponse,
TagRecord,
TaxonomyPayload,
UpdateCommentPayload,
UpdatePostPayload,
UpdateReviewPayload,
} from '@/lib/types'
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
const PROD_DEFAULT_API_PORT = '5150'
const DEV_DEFAULT_API_HOST = '127.0.0.1'
function getApiBase() {
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
if (runtimeApiBase) {
return runtimeApiBase
}
if (envApiBase) {
return envApiBase
}
if (import.meta.env.DEV) {
if (typeof window !== 'undefined') {
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
}
return `http://${DEV_DEFAULT_API_HOST}:${PROD_DEFAULT_API_PORT}`
}
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
}
const API_BASE = getApiBase()
export class ApiError extends Error {
status: number
@@ -85,6 +154,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers,
})
@@ -116,7 +186,174 @@ export const adminApi = {
request<AdminSessionResponse>('/api/admin/session', {
method: 'DELETE',
}),
listAuditLogs: (query?: { action?: string; targetType?: string; limit?: number }) =>
request<AuditLogRecord[]>(
appendQueryParams('/api/admin/audit-logs', {
action: query?.action,
target_type: query?.targetType,
limit: query?.limit,
}),
),
listPostRevisions: (query?: { slug?: string; limit?: number }) =>
request<PostRevisionRecord[]>(
appendQueryParams('/api/admin/post-revisions', {
slug: query?.slug,
limit: query?.limit,
}),
),
getPostRevision: (id: number) => request<PostRevisionDetail>(`/api/admin/post-revisions/${id}`),
restorePostRevision: (id: number, mode: 'full' | 'markdown' | 'metadata' = 'full') =>
request<RestoreRevisionResponse>(`/api/admin/post-revisions/${id}/restore`, {
method: 'POST',
body: JSON.stringify({ mode }),
}),
listSubscriptions: async () =>
(await request<SubscriptionListResponse>('/api/admin/subscriptions')).subscriptions,
createSubscription: (payload: SubscriptionPayload) =>
request<SubscriptionRecord>('/api/admin/subscriptions', {
method: 'POST',
body: JSON.stringify({
channelType: payload.channelType,
target: payload.target,
displayName: payload.displayName,
status: payload.status,
filters: payload.filters,
metadata: payload.metadata,
secret: payload.secret,
notes: payload.notes,
}),
}),
updateSubscription: (id: number, payload: SubscriptionUpdatePayload) =>
request<SubscriptionRecord>(`/api/admin/subscriptions/${id}`, {
method: 'PATCH',
body: JSON.stringify({
channelType: payload.channelType,
target: payload.target,
displayName: payload.displayName,
status: payload.status,
filters: payload.filters,
metadata: payload.metadata,
secret: payload.secret,
notes: payload.notes,
}),
}),
deleteSubscription: (id: number) =>
request<void>(`/api/admin/subscriptions/${id}`, {
method: 'DELETE',
}),
testSubscription: (id: number) =>
request<{ queued: boolean; id: number; delivery_id: number; job_id?: number | null }>(`/api/admin/subscriptions/${id}/test`, {
method: 'POST',
}),
listSubscriptionDeliveries: async (limit = 80) =>
(await request<{ deliveries: NotificationDeliveryRecord[] }>(
appendQueryParams('/api/admin/subscriptions/deliveries', { limit }),
)).deliveries,
sendSubscriptionDigest: (period: 'weekly' | 'monthly') =>
request<SubscriptionDigestResponse>('/api/admin/subscriptions/digest', {
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'),
createCategory: (payload: TaxonomyPayload) =>
request<CategoryRecord>('/api/admin/categories', {
method: 'POST',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
updateCategory: (id: number, payload: TaxonomyPayload) =>
request<CategoryRecord>(`/api/admin/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
deleteCategory: (id: number) =>
request<void>(`/api/admin/categories/${id}`, {
method: 'DELETE',
}),
listTags: () => request<TagRecord[]>('/api/admin/tags'),
createTag: (payload: TaxonomyPayload) =>
request<TagRecord>('/api/admin/tags', {
method: 'POST',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
updateTag: (id: number, payload: TaxonomyPayload) =>
request<TagRecord>(`/api/admin/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify({
name: payload.name,
slug: payload.slug,
description: payload.description,
cover_image: payload.coverImage,
accent_color: payload.accentColor,
seo_title: payload.seoTitle,
seo_description: payload.seoDescription,
}),
}),
deleteTag: (id: number) =>
request<void>(`/api/admin/tags/${id}`, {
method: 'DELETE',
}),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
updateSiteSettings: (payload: SiteSettingsPayload) =>
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
@@ -124,7 +361,7 @@ export const adminApi = {
body: JSON.stringify(payload),
}),
reindexAi: () =>
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
request<WorkerTaskActionResponse>('/api/admin/ai/reindex', {
method: 'POST',
}),
testAiProvider: (provider: {
@@ -139,6 +376,111 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ provider }),
}),
testAiImageProvider: (provider: {
provider: string
api_base: string | null
api_key: string | null
image_model: string | null
}) =>
request<AdminAiImageProviderTestResponse>('/api/admin/ai/test-image-provider', {
method: 'POST',
body: JSON.stringify({
provider: provider.provider,
api_base: provider.api_base,
api_key: provider.api_key,
image_model: provider.image_model,
}),
}),
uploadReviewCoverImage: (file: File) => {
const formData = new FormData()
formData.append('file', file, file.name)
return request<AdminImageUploadResponse>('/api/admin/storage/review-cover', {
method: 'POST',
body: formData,
})
},
testR2Storage: () =>
request<AdminR2ConnectivityResponse>('/api/admin/storage/r2/test', {
method: 'POST',
}),
listMediaObjects: (query?: { prefix?: string; limit?: number }) =>
request<AdminMediaListResponse>(
appendQueryParams('/api/admin/storage/media', {
prefix: query?.prefix,
limit: query?.limit,
}),
),
deleteMediaObject: (key: string) =>
request<AdminMediaDeleteResponse>(
`/api/admin/storage/media?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
},
),
uploadMediaObjects: (files: File[], options?: { prefix?: string }) => {
const formData = new FormData()
if (options?.prefix) {
formData.append('prefix', options.prefix)
}
files.forEach((file) => {
formData.append('files', file, file.name)
})
return request<AdminMediaUploadResponse>('/api/admin/storage/media', {
method: 'POST',
body: formData,
})
},
batchDeleteMediaObjects: (keys: string[]) =>
request<AdminMediaBatchDeleteResponse>('/api/admin/storage/media/batch-delete', {
method: 'POST',
body: JSON.stringify({ keys }),
}),
replaceMediaObject: (key: string, file: File) => {
const formData = new FormData()
formData.append('key', key)
formData.append('file', file, file.name)
return request<AdminMediaReplaceResponse>('/api/admin/storage/media/replace', {
method: 'POST',
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',
body: JSON.stringify({
key: payload.key,
title: payload.title,
alt_text: payload.altText,
caption: payload.caption,
tags: payload.tags,
notes: payload.notes,
}),
}),
exportSiteBackup: () => request<SiteBackupDocument>('/api/admin/site-backup/export'),
importSiteBackup: (payload: SiteBackupImportPayload) =>
request<SiteBackupImportResponse>('/api/admin/site-backup/import', {
method: 'POST',
body: JSON.stringify(payload),
}),
generatePostMetadata: (markdown: string) =>
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
method: 'POST',
@@ -149,6 +491,40 @@ 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',
body: JSON.stringify({
title: payload.title,
review_type: payload.reviewType,
rating: payload.rating,
review_date: payload.reviewDate,
status: payload.status,
tags: payload.tags,
description: payload.description,
}),
}),
generatePostCoverImage: (payload: AdminPostCoverImageRequest) =>
request<AdminPostCoverImageResponse>('/api/admin/ai/post-cover', {
method: 'POST',
body: JSON.stringify({
title: payload.title,
description: payload.description,
category: payload.category,
tags: payload.tags,
post_type: payload.postType,
slug: payload.slug,
markdown: payload.markdown,
}),
}),
listPosts: (query?: PostListQuery) =>
request<PostRecord[]>(
appendQueryParams('/api/posts', {
@@ -158,9 +534,37 @@ export const adminApi = {
search: query?.search,
type: query?.postType,
pinned: query?.pinned,
status: query?.status,
visibility: query?.visibility,
listed_only: query?.listedOnly,
include_private: query?.includePrivate ?? true,
include_redirects: query?.includeRedirects ?? true,
preview: query?.preview ?? true,
}),
),
getPostBySlug: (slug: string) => request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}`),
listPostsPage: (query?: PostListQuery) =>
request<PostPageResponse>(
appendQueryParams('/api/posts/page', {
slug: query?.slug,
category: query?.category,
tag: query?.tag,
search: query?.search,
type: query?.postType,
pinned: query?.pinned,
status: query?.status,
visibility: query?.visibility,
listed_only: query?.listedOnly,
include_private: query?.includePrivate ?? true,
include_redirects: query?.includeRedirects ?? true,
preview: query?.preview ?? true,
page: query?.page,
page_size: query?.pageSize,
sort_by: query?.sortBy,
sort_order: query?.sortOrder,
}),
),
getPostBySlug: (slug: string) =>
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
createPost: (payload: CreatePostPayload) =>
request<MarkdownDocumentResponse>('/api/posts/markdown', {
method: 'POST',
@@ -175,6 +579,15 @@ export const adminApi = {
image: payload.image,
images: payload.images,
pinned: payload.pinned,
status: payload.status,
visibility: payload.visibility,
publish_at: payload.publishAt,
unpublish_at: payload.unpublishAt,
canonical_url: payload.canonicalUrl,
noindex: payload.noindex,
og_image: payload.ogImage,
redirect_from: payload.redirectFrom,
redirect_to: payload.redirectTo,
published: payload.published,
}),
}),
@@ -192,6 +605,15 @@ export const adminApi = {
image: payload.image,
images: payload.images,
pinned: payload.pinned,
status: payload.status,
visibility: payload.visibility,
publish_at: payload.publishAt,
unpublish_at: payload.unpublishAt,
canonical_url: payload.canonicalUrl,
noindex: payload.noindex,
og_image: payload.ogImage,
redirect_from: payload.redirectFrom,
redirect_to: payload.redirectTo,
}),
}),
getPostMarkdown: (slug: string) =>
@@ -236,6 +658,59 @@ export const adminApi = {
request<void>(`/api/comments/${id}`, {
method: 'DELETE',
}),
listCommentBlacklist: () =>
request<CommentBlacklistRecord[]>('/api/admin/comments/blacklist'),
createCommentBlacklist: (payload: {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
reason?: string | null
active?: boolean
expires_at?: string | null
}) =>
request<CommentBlacklistRecord>('/api/admin/comments/blacklist', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateCommentBlacklist: (
id: number,
payload: {
reason?: string | null
active?: boolean
expires_at?: string | null
clear_expires_at?: boolean
},
) =>
request<CommentBlacklistRecord>(`/api/admin/comments/blacklist/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteCommentBlacklist: (id: number) =>
request<{ deleted: boolean; id: number }>(`/api/admin/comments/blacklist/${id}`, {
method: 'DELETE',
}),
analyzeCommentPersona: (payload: {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
from?: string | null
to?: string | null
limit?: number
}) =>
request<CommentPersonaAnalysisResponse>('/api/admin/comments/analyze', {
method: 'POST',
body: JSON.stringify(payload),
}),
listCommentPersonaAnalysisLogs: (query?: {
matcher_type?: 'ip' | 'email' | 'user_agent' | string
matcher_value?: string
limit?: number
}) =>
request<CommentPersonaAnalysisLogRecord[]>(
appendQueryParams('/api/admin/comments/analyze/logs', {
matcher_type: query?.matcher_type,
matcher_value: query?.matcher_value,
limit: query?.limit,
}),
),
listFriendLinks: (query?: FriendLinkListQuery) =>
request<FriendLinkRecord[]>(
appendQueryParams('/api/friend_links', {

View File

@@ -0,0 +1,28 @@
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
const envFrontendBaseUrl = normalizeAdminBaseUrl(import.meta.env.VITE_FRONTEND_BASE_URL)
const DEV_FRONTEND_BASE_URL = 'http://localhost:4321'
const PROD_DEFAULT_FRONTEND_PORT = '4321'
export function getFrontendBaseUrl() {
const runtimeFrontendBaseUrl = getRuntimeAdminBaseUrl('frontendBaseUrl')
if (runtimeFrontendBaseUrl) {
return runtimeFrontendBaseUrl
}
if (envFrontendBaseUrl) {
return envFrontendBaseUrl
}
if (import.meta.env.DEV || typeof window === 'undefined') {
return DEV_FRONTEND_BASE_URL
}
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:${PROD_DEFAULT_FRONTEND_PORT}`
}
export function buildFrontendUrl(path = '/') {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${getFrontendBaseUrl()}${normalizedPath}`
}

View File

@@ -0,0 +1,791 @@
export interface CompressionPreview {
originalSize: number
compressedSize: number
savedBytes: number
savedRatio: number
}
export interface CompressionResult {
file: File
usedCompressed: boolean
preview: CompressionPreview | null
}
export type MediaUploadTargetFormat = 'auto' | 'avif' | 'webp'
interface ProcessedVariant {
file: File
preview: CompressionPreview
}
interface ProcessImageOptions {
quality: number
maxWidth: number
maxHeight: number
preferredFormats: string[]
coverWidth?: number
coverHeight?: number
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unit = 0
while (size >= 1024 && unit < units.length - 1) {
size /= 1024
unit += 1
}
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
}
function canTransformWithCanvas(file: File) {
return file.type.startsWith('image/') && file.type !== 'image/svg+xml' && file.type !== 'image/gif'
}
async function canvasToBlob(
canvas: HTMLCanvasElement,
preferredFormats: string[],
quality: number,
) {
for (const format of preferredFormats) {
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, format, quality)
})
if (blob && blob.type === format) {
return blob
}
}
return new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
}
function extensionForMimeType(mimeType: string) {
switch (mimeType) {
case 'image/avif':
return '.avif'
case 'image/webp':
return '.webp'
case 'image/png':
return '.png'
default:
return '.jpg'
}
}
function deriveFileName(file: File, mimeType: string) {
const extension = extensionForMimeType(mimeType)
if (/\.[A-Za-z0-9]+$/.test(file.name)) {
return file.name.replace(/\.[A-Za-z0-9]+$/, extension)
}
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
}
const bitmap = await createImageBitmap(file)
const canvas = document.createElement('canvas')
if (options.coverWidth && options.coverHeight) {
canvas.width = options.coverWidth
canvas.height = options.coverHeight
} else {
const scale = Math.min(
options.maxWidth / bitmap.width,
options.maxHeight / bitmap.height,
1,
)
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
}
const ctx = canvas.getContext('2d')
if (!ctx) {
return file
}
if (options.coverWidth && options.coverHeight) {
const scale = Math.max(
options.coverWidth / bitmap.width,
options.coverHeight / bitmap.height,
)
const drawWidth = bitmap.width * scale
const drawHeight = bitmap.height * scale
const offsetX = (options.coverWidth - drawWidth) / 2
const offsetY = (options.coverHeight - drawHeight) / 2
ctx.drawImage(bitmap, offsetX, offsetY, drawWidth, drawHeight)
} else {
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
}
const blob = await canvasToBlob(canvas, options.preferredFormats, options.quality)
if (!blob) {
return file
}
return new File([blob], deriveFileName(file, blob.type), {
type: blob.type,
lastModified: Date.now(),
})
}
async function maybeProcessImageWithPrompt(
file: File,
options?: {
quality?: number
ask?: boolean
minSavingsRatio?: number
contextLabel?: string
maxWidth?: number
maxHeight?: number
preferredFormats?: string[]
coverWidth?: number
coverHeight?: number
forceProcessed?: boolean
},
): Promise<CompressionResult> {
if (!canTransformWithCanvas(file)) {
return { file, usedCompressed: false, preview: null }
}
const quality = Math.min(Math.max(options?.quality ?? 0.82, 0.45), 0.95)
const minSavingsRatio = Math.min(Math.max(options?.minSavingsRatio ?? 0.03, 0), 0.9)
const ask = options?.ask ?? true
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)
} catch {
return { file, usedCompressed: false, preview: null }
}
const preview = buildPreview(file.size, processed.size)
const { savedRatio } = preview
if (!forceProcessed && processed.size >= file.size) {
return { file, usedCompressed: false, preview }
}
if (!forceProcessed && savedRatio < minSavingsRatio) {
return { file, usedCompressed: false, preview }
}
if (!ask) {
return { file: processed, usedCompressed: true, preview }
}
let variants: ProcessedVariant[]
try {
variants = await buildProcessedVariants(file, processOptions)
} catch {
variants = [
{
file: processed,
preview,
},
]
}
const selectableVariants = forceProcessed
? variants
: variants.filter((item) => item.file.size < file.size && item.preview.savedRatio >= minSavingsRatio)
if (!selectableVariants.length) {
return { file, usedCompressed: false, preview }
}
const recommendedVariant = selectableVariants[0]
const missingPreferredFormats = processOptions.preferredFormats.filter(
(format) => !variants.some((item) => item.file.type === format),
)
const note =
missingPreferredFormats.length > 0
? `当前环境未提供 ${missingPreferredFormats.map(formatLabelForMimeType).join(' / ')} 编码能力,因此这里只展示可实际生成的格式。`
: undefined
const choice = await showImageChoiceDialog({
title: forceProcessed ? `${contextLabel}:已生成规范化版本` : `${contextLabel}:检测到可压缩空间`,
description: forceProcessed
? '可以直接保留原图,也可以选择更适合上传的规范化版本。'
: '可以直接保留原图,也可以选择体积更合适的版本再上传。',
note,
choices: [
{
id: 'original',
title: `保留原图(${file.name}`,
meta: [
`当前文件: ${formatBytes(file.size)}`,
`格式: ${formatLabelForMimeType(file.type || 'image/jpeg')}`,
],
badge: '原图',
},
...selectableVariants.map((item, index) => {
const variantSavedBytes = item.preview.savedBytes
const variantSavedRatio = item.preview.savedRatio
return {
id: item.file.type,
title: `${formatLabelForMimeType(item.file.type)} 版本`,
meta: [
`处理后: ${formatBytes(item.file.size)}`,
variantSavedBytes >= 0
? `节省: ${formatBytes(variantSavedBytes)} (${(variantSavedRatio * 100).toFixed(1)}%)`
: `体积增加: ${formatBytes(Math.abs(variantSavedBytes))} (${Math.abs(variantSavedRatio * 100).toFixed(1)}%)`,
],
badge: item.file.name.replace(/^.*(\.[A-Za-z0-9]+)$/, '$1').toLowerCase(),
recommended: index === 0,
}
}),
],
defaultChoiceId: recommendedVariant.file.type,
confirmLabel: '使用所选版本',
cancelLabel: '保留原图',
})
const selectedVariant = selectableVariants.find((item) => item.file.type === choice)
const useProcessed = Boolean(selectedVariant)
return {
file: selectedVariant?.file ?? file,
usedCompressed: useProcessed,
preview: selectedVariant?.preview ?? preview,
}
}
export async function maybeCompressImageWithPrompt(
file: File,
options?: {
quality?: number
ask?: boolean
minSavingsRatio?: number
contextLabel?: string
preferredFormats?: string[]
},
): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, options)
}
export async function normalizeCoverImageWithPrompt(
file: File,
options?: {
quality?: number
ask?: boolean
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'],
coverWidth: Math.max(options?.width ?? 1600, 640),
coverHeight: Math.max(options?.height ?? 900, 360),
forceProcessed: true,
minSavingsRatio: 0,
})
}
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 ''
}
if (preview.savedBytes >= 0) {
return `原始 ${formatBytes(preview.originalSize)} → 处理后 ${formatBytes(
preview.compressedSize,
)},节省 ${(preview.savedRatio * 100).toFixed(1)}%`
}
return `原始 ${formatBytes(preview.originalSize)} → 处理后 ${formatBytes(
preview.compressedSize,
)},体积增加 ${Math.abs(preview.savedRatio * 100).toFixed(1)}%`
}

View File

@@ -9,7 +9,15 @@ export type ParsedMarkdownMeta = {
image: string
images: string[]
pinned: boolean
published: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFrom: string[]
redirectTo: string
tags: string[]
}
@@ -28,7 +36,15 @@ const defaultMeta: ParsedMarkdownMeta = {
image: '',
images: [],
pinned: false,
published: true,
status: 'published',
visibility: 'public',
publishAt: '',
unpublishAt: '',
canonicalUrl: '',
noindex: false,
ogImage: '',
redirectFrom: [],
redirectTo: '',
tags: [],
}
@@ -102,7 +118,7 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
const frontmatter = normalized.slice(4, endIndex)
const body = normalized.slice(endIndex + 5).trimStart()
let currentListKey: 'tags' | 'images' | 'categories' | null = null
let currentListKey: 'tags' | 'images' | 'categories' | 'redirect_from' | null = null
const categories: string[] = []
frontmatter.split('\n').forEach((line) => {
@@ -118,6 +134,8 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
meta.tags.push(nextValue)
} else if (currentListKey === 'images') {
meta.images.push(nextValue)
} else if (currentListKey === 'redirect_from') {
meta.redirectFrom.push(nextValue)
} else {
categories.push(nextValue)
}
@@ -155,6 +173,16 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
return
}
if (key === 'redirect_from') {
const redirectFrom = toStringList(value)
if (redirectFrom.length) {
meta.redirectFrom = redirectFrom
} else if (!String(rawValue).trim()) {
currentListKey = 'redirect_from'
}
return
}
if (key === 'categories' || key === 'category') {
const parsedCategories = toStringList(value)
if (parsedCategories.length) {
@@ -184,12 +212,36 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
case 'pinned':
meta.pinned = Boolean(value)
break
case 'status':
meta.status = String(value).trim() || 'published'
break
case 'visibility':
meta.visibility = String(value).trim() || 'public'
break
case 'publish_at':
meta.publishAt = String(value).trim()
break
case 'unpublish_at':
meta.unpublishAt = String(value).trim()
break
case 'canonical_url':
meta.canonicalUrl = String(value).trim()
break
case 'noindex':
meta.noindex = Boolean(value)
break
case 'og_image':
meta.ogImage = String(value).trim()
break
case 'redirect_to':
meta.redirectTo = String(value).trim()
break
case 'published':
meta.published = value !== false
meta.status = value === false ? 'draft' : 'published'
break
case 'draft':
if (value === true) {
meta.published = false
meta.status = 'draft'
}
break
default:
@@ -223,7 +275,17 @@ export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`)
lines.push(`pinned: ${meta.pinned ? 'true' : 'false'}`)
lines.push(`published: ${meta.published ? 'true' : 'false'}`)
lines.push(`status: ${JSON.stringify(meta.status.trim() || 'published')}`)
lines.push(`visibility: ${JSON.stringify(meta.visibility.trim() || 'public')}`)
lines.push(`noindex: ${meta.noindex ? 'true' : 'false'}`)
if (meta.publishAt.trim()) {
lines.push(`publish_at: ${JSON.stringify(meta.publishAt.trim())}`)
}
if (meta.unpublishAt.trim()) {
lines.push(`unpublish_at: ${JSON.stringify(meta.unpublishAt.trim())}`)
}
if (meta.image.trim()) {
lines.push(`image: ${JSON.stringify(meta.image.trim())}`)
@@ -243,5 +305,24 @@ export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
})
}
if (meta.canonicalUrl.trim()) {
lines.push(`canonical_url: ${JSON.stringify(meta.canonicalUrl.trim())}`)
}
if (meta.ogImage.trim()) {
lines.push(`og_image: ${JSON.stringify(meta.ogImage.trim())}`)
}
if (meta.redirectFrom.length) {
lines.push('redirect_from:')
meta.redirectFrom.forEach((item) => {
lines.push(` - ${JSON.stringify(item)}`)
})
}
if (meta.redirectTo.trim()) {
lines.push(`redirect_to: ${JSON.stringify(meta.redirectTo.trim())}`)
}
return `${lines.join('\n')}\n---\n\n${body.trim()}\n`
}

View File

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

View File

@@ -0,0 +1,22 @@
export type TermiAdminRuntimeConfig = {
apiBaseUrl?: string
frontendBaseUrl?: string
}
declare global {
interface Window {
__TERMI_ADMIN_RUNTIME_CONFIG__?: TermiAdminRuntimeConfig
}
}
export function normalizeAdminBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? ''
}
export function getRuntimeAdminBaseUrl(key: keyof TermiAdminRuntimeConfig) {
if (typeof window === 'undefined') {
return ''
}
return normalizeAdminBaseUrl(window.__TERMI_ADMIN_RUNTIME_CONFIG__?.[key])
}

View File

@@ -1,12 +1,213 @@
export interface AdminSessionResponse {
authenticated: boolean
username: string | null
email: string | null
auth_source: string | null
auth_provider: string | null
groups: string[]
proxy_auth_enabled: boolean
local_login_enabled: boolean
can_logout: boolean
}
export interface AuditLogRecord {
created_at: string
updated_at: string
id: number
actor_username: string | null
actor_email: string | null
actor_source: string | null
action: string
target_type: string
target_id: string | null
target_label: string | null
metadata: Record<string, unknown> | null
}
export interface PostRevisionRecord {
id: number
post_slug: string
post_title: string | null
operation: string
revision_reason: string | null
actor_username: string | null
actor_email: string | null
actor_source: string | null
created_at: string
has_markdown: boolean
metadata: Record<string, unknown> | null
}
export interface PostRevisionDetail {
item: PostRevisionRecord
markdown: string | null
}
export interface RestoreRevisionResponse {
restored: boolean
revision_id: number
post_slug: string
mode: 'full' | 'markdown' | 'metadata' | string
}
export interface SubscriptionRecord {
created_at: string
updated_at: string
id: number
channel_type: string
target: string
display_name: string | null
status: string
filters: Record<string, unknown> | null
metadata: Record<string, unknown> | null
secret: string | null
notes: string | null
confirm_token: string | null
manage_token: string | null
verified_at: string | null
last_notified_at: string | null
failure_count: number | null
last_delivery_status: string | null
}
export interface NotificationDeliveryRecord {
created_at: string
updated_at: string
id: number
subscription_id: number | null
channel_type: string
target: string
event_type: string
status: string
provider: string | null
response_text: string | null
payload: Record<string, unknown> | null
attempts_count: number
next_retry_at: string | null
last_attempt_at: string | null
delivered_at: string | null
}
export interface SubscriptionListResponse {
subscriptions: SubscriptionRecord[]
}
export interface DeliveryListResponse {
deliveries: NotificationDeliveryRecord[]
}
export interface SubscriptionPayload {
channelType: string
target: string
displayName?: string | null
status?: string | null
filters?: Record<string, unknown> | null
metadata?: Record<string, unknown> | null
secret?: string | null
notes?: string | null
}
export interface SubscriptionUpdatePayload {
channelType?: string | null
target?: string | null
displayName?: string | null
status?: string | null
filters?: Record<string, unknown> | null
metadata?: Record<string, unknown> | null
secret?: string | null
notes?: string | null
}
export interface SubscriptionDigestResponse {
period: string
post_count: number
queued: number
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
pending_comments: number
draft_posts: number
scheduled_posts: number
offline_posts: number
expired_posts: number
private_posts: number
unlisted_posts: number
total_categories: number
total_tags: number
total_reviews: number
@@ -23,6 +224,8 @@ export interface DashboardPostItem {
category: string
post_type: string
pinned: boolean
status: string
visibility: string
created_at: string
}
@@ -71,6 +274,87 @@ export interface AdminDashboardResponse {
recent_reviews: DashboardReviewItem[]
}
export interface AnalyticsOverview {
total_searches: number
total_ai_questions: number
searches_last_24h: number
ai_questions_last_24h: number
searches_last_7d: number
ai_questions_last_7d: number
unique_search_terms_last_7d: number
unique_ai_questions_last_7d: number
avg_search_results_last_7d: number
avg_ai_latency_ms_last_7d: number | null
}
export interface ContentAnalyticsOverview {
total_page_views: number
page_views_last_24h: number
page_views_last_7d: number
total_read_completes: number
read_completes_last_7d: number
avg_read_progress_last_7d: number
avg_read_duration_ms_last_7d: number | null
}
export interface AnalyticsTopQuery {
query: string
count: number
last_seen_at: string
}
export interface AnalyticsRecentEvent {
id: number
event_type: string
query: string
result_count: number | null
success: boolean | null
response_mode: string | null
provider: string | null
chat_model: string | null
latency_ms: number | null
created_at: string
}
export interface AnalyticsProviderBucket {
provider: string
count: number
}
export interface AnalyticsReferrerBucket {
referrer: string
count: number
}
export interface AnalyticsPopularPost {
slug: string
title: string
page_views: number
read_completes: number
avg_progress_percent: number
avg_duration_ms: number | null
}
export interface AnalyticsDailyBucket {
date: string
searches: number
ai_questions: number
}
export interface AdminAnalyticsResponse {
overview: AnalyticsOverview
content_overview: ContentAnalyticsOverview
top_search_terms: AnalyticsTopQuery[]
top_ai_questions: AnalyticsTopQuery[]
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[]
}
export interface AdminSiteSettingsResponse {
id: number
site_name: string | null
@@ -90,12 +374,29 @@ 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
comment_turnstile_enabled: boolean
subscription_verification_mode: HumanVerificationMode
subscription_turnstile_enabled: boolean
web_push_enabled: boolean
turnstile_site_key: string | null
turnstile_secret_key: string | null
web_push_vapid_public_key: string | null
web_push_vapid_private_key: string | null
web_push_vapid_subject: string | null
ai_provider: string | null
ai_api_base: string | null
ai_api_key: string | null
ai_chat_model: string | null
ai_image_provider: string | null
ai_image_api_base: string | null
ai_image_api_key: string | null
ai_image_model: string | null
ai_providers: AiProviderConfig[]
ai_active_provider_id: string | null
ai_embedding_model: string | null
@@ -105,6 +406,25 @@ export interface AdminSiteSettingsResponse {
ai_last_indexed_at: string | null
ai_chunks_count: number
ai_local_embedding: string
media_storage_provider: string | null
media_r2_account_id: string | null
media_r2_bucket: string | null
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
notification_friend_link_enabled: boolean
subscription_popup_enabled: boolean
subscription_popup_title: string
subscription_popup_description: string
subscription_popup_delay_seconds: number
search_synonyms: string[]
}
export interface AiProviderConfig {
@@ -114,6 +434,7 @@ export interface AiProviderConfig {
api_base: string | null
api_key: string | null
chat_model: string | null
image_model: string | null
}
export interface SiteSettingsPayload {
@@ -134,23 +455,94 @@ export interface SiteSettingsPayload {
location?: string | null
techStack?: string[]
musicPlaylist?: MusicTrack[]
musicEnabled?: boolean
maintenanceModeEnabled?: boolean
maintenanceAccessCode?: string | null
aiEnabled?: boolean
paragraphCommentsEnabled?: boolean
commentVerificationMode?: HumanVerificationMode | null
commentTurnstileEnabled?: boolean
subscriptionVerificationMode?: HumanVerificationMode | null
subscriptionTurnstileEnabled?: boolean
webPushEnabled?: boolean
turnstileSiteKey?: string | null
turnstileSecretKey?: string | null
webPushVapidPublicKey?: string | null
webPushVapidPrivateKey?: string | null
webPushVapidSubject?: string | null
aiProvider?: string | null
aiApiBase?: string | null
aiApiKey?: string | null
aiChatModel?: string | null
aiImageProvider?: string | null
aiImageApiBase?: string | null
aiImageApiKey?: string | null
aiImageModel?: string | null
aiProviders?: AiProviderConfig[]
aiActiveProviderId?: string | null
aiEmbeddingModel?: string | null
aiSystemPrompt?: string | null
aiTopK?: number | null
aiChunkSize?: number | null
mediaStorageProvider?: string | null
mediaR2AccountId?: string | null
mediaR2Bucket?: string | null
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
notificationFriendLinkEnabled?: boolean
subscriptionPopupEnabled?: boolean
subscriptionPopupTitle?: string | null
subscriptionPopupDescription?: string | null
subscriptionPopupDelaySeconds?: number | null
searchSynonyms?: string[]
}
export interface AdminAiReindexResponse {
indexed_chunks: number
last_indexed_at: string | null
export type HumanVerificationMode = 'off' | 'captcha' | 'turnstile' | string
export interface CategoryRecord {
id: number
name: string
slug: string
count: number
description: string | null
cover_image: string | null
accent_color: string | null
seo_title: string | null
seo_description: string | null
created_at: string
updated_at: string
}
export interface TagRecord {
id: number
name: string
slug: string
count: number
description: string | null
cover_image: string | null
accent_color: string | null
seo_title: string | null
seo_description: string | null
created_at: string
updated_at: string
}
export interface TaxonomyPayload {
name: string
slug?: string | null
description?: string | null
coverImage?: string | null
accentColor?: string | null
seoTitle?: string | null
seoDescription?: string | null
}
export interface AdminAiProviderTestResponse {
@@ -160,6 +552,195 @@ export interface AdminAiProviderTestResponse {
reply_preview: string
}
export interface AdminAiImageProviderTestResponse {
provider: string
endpoint: string
image_model: string
result_preview: string
}
export interface AdminImageUploadResponse {
url: string
key: string
}
export interface AdminR2ConnectivityResponse {
bucket: string
public_base_url: string
}
export interface AdminMediaObjectResponse {
key: string
url: string
size_bytes: number
last_modified: string | null
title: string | null
alt_text: string | null
caption: string | null
tags: string[]
notes: string | null
}
export interface AdminMediaListResponse {
provider: string
bucket: string
public_base_url: string
items: AdminMediaObjectResponse[]
}
export interface AdminMediaDeleteResponse {
deleted: boolean
key: string
}
export interface AdminMediaUploadItem {
key: string
url: string
size_bytes: number
}
export interface AdminMediaUploadResponse {
uploaded: AdminMediaUploadItem[]
}
export interface AdminMediaBatchDeleteResponse {
deleted: string[]
failed: string[]
}
export interface AdminMediaReplaceResponse {
key: string
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
altText?: string | null
caption?: string | null
tags?: string[]
notes?: string | null
}
export interface AdminMediaMetadataResponse {
saved: boolean
key: string
title: string | null
alt_text: string | null
caption: string | null
tags: string[]
notes: string | null
}
export interface SiteBackupDocument {
version: string
exported_at: string
includes_storage_binaries: boolean
warning: string
site_settings: Record<string, unknown>
categories: Record<string, unknown>[]
tags: Record<string, unknown>[]
reviews: Record<string, unknown>[]
friend_links: Record<string, unknown>[]
media_assets: Record<string, unknown>[]
storage_manifest?: Record<string, unknown>[] | null
posts: Array<{
slug: string
file_name: string
markdown: string
}>
}
export interface SiteBackupImportPayload {
backup: SiteBackupDocument
mode?: 'merge' | 'replace' | string
}
export interface SiteBackupImportResponse {
imported: boolean
mode: string
site_settings_restored: boolean
posts_written: number
categories_upserted: number
tags_upserted: number
reviews_upserted: number
friend_links_upserted: number
media_assets_upserted: number
storage_manifest_items: number
includes_storage_binaries: boolean
warning: string
}
export interface CommentBlacklistRecord {
id: number
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
reason: string | null
active: boolean
expires_at: string | null
created_at: string
updated_at: string
effective: boolean
}
export interface CommentPersonaAnalysisSample {
id: number
created_at: string
post_slug: string
author: string
email: string
approved: boolean
content_preview: string
}
export interface CommentPersonaAnalysisResponse {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
total_comments: number
pending_comments: number
first_seen_at: string | null
latest_seen_at: string | null
distinct_posts: number
analysis: string
samples: CommentPersonaAnalysisSample[]
}
export interface CommentPersonaAnalysisLogRecord {
id: number
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
from_at: string | null
to_at: string | null
total_comments: number
pending_comments: number
distinct_posts: number
analysis: string
samples: CommentPersonaAnalysisSample[]
created_at: string
}
export interface MusicTrack {
title: string
artist?: string | null
@@ -182,6 +763,56 @@ 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
rating: number
reviewDate?: string | null
status: string
tags: string[]
description: string
}
export interface AdminReviewPolishResponse {
polished_description: string
}
export interface AdminPostCoverImageRequest {
title: string
description?: string | null
category?: string | null
tags: string[]
postType: string
slug?: string | null
markdown: string
}
export interface AdminPostCoverImageResponse {
image_url: string
prompt: string
}
export interface PostRecord {
created_at: string
updated_at: string
@@ -196,6 +827,15 @@ export interface PostRecord {
image: string | null
images: string[] | null
pinned: boolean | null
status: string | null
visibility: string | null
publish_at: string | null
unpublish_at: string | null
canonical_url: string | null
noindex: boolean | null
og_image: string | null
redirect_from: string[] | null
redirect_to: string | null
}
export interface PostListQuery {
@@ -205,6 +845,26 @@ export interface PostListQuery {
search?: string
postType?: string
pinned?: boolean
status?: string
visibility?: string
listedOnly?: boolean
includePrivate?: boolean
includeRedirects?: boolean
preview?: boolean
page?: number
pageSize?: number
sortBy?: 'created_at' | 'updated_at' | 'title' | string
sortOrder?: 'asc' | 'desc' | string
}
export interface PostPageResponse {
items: PostRecord[]
page: number
page_size: number
total: number
total_pages: number
sort_by: string
sort_order: string
}
export interface CreatePostPayload {
@@ -218,6 +878,15 @@ export interface CreatePostPayload {
image?: string | null
images?: string[] | null
pinned?: boolean
status?: string | null
visibility?: string | null
publishAt?: string | null
unpublishAt?: string | null
canonicalUrl?: string | null
noindex?: boolean
ogImage?: string | null
redirectFrom?: string[]
redirectTo?: string | null
published?: boolean
}
@@ -232,6 +901,15 @@ export interface UpdatePostPayload {
image?: string | null
images?: string[] | null
pinned?: boolean | null
status?: string | null
visibility?: string | null
publishAt?: string | null
unpublishAt?: string | null
canonicalUrl?: string | null
noindex?: boolean | null
ogImage?: string | null
redirectFrom?: string[]
redirectTo?: string | null
}
export interface MarkdownDocumentResponse {
@@ -259,6 +937,9 @@ export interface CommentRecord {
author: string | null
email: string | null
avatar: string | null
ip_address: string | null
user_agent: string | null
referer: string | null
content: string | null
scope: string
paragraph_key: string | null

View File

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

View File

@@ -0,0 +1,681 @@
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search, Sparkles } 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 { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import type { AdminAnalyticsResponse } from '@/lib/types'
function StatCard({
label,
value,
note,
icon: Icon,
}: {
label: string
value: string
note: string
icon: typeof Search
}) {
return (
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="flex items-start justify-between pt-6">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
}
function formatEventType(value: string) {
return value === 'ai_question' ? 'AI 问答' : '站内搜索'
}
function formatSuccess(value: boolean | null) {
if (value === null) {
return '未记录'
}
return value ? '成功' : '失败'
}
function formatPercent(value: number) {
return `${Math.round(value)}%`
}
function formatDuration(value: number | null) {
if (value === null || !Number.isFinite(value) || value <= 0) {
return '暂无'
}
if (value < 1000) {
return `${Math.round(value)} ms`
}
const seconds = value / 1000
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}`
}
const minutes = Math.floor(seconds / 60)
const restSeconds = Math.round(seconds % 60)
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)
const [refreshing, setRefreshing] = useState(false)
const loadAnalytics = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.analytics()
startTransition(() => {
setData(next)
})
if (showToast) {
toast.success('数据分析已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadAnalytics(false)
}, [loadAnalytics])
const maxDailyTotal = useMemo(() => {
if (!data?.daily_activity.length) {
return 1
}
return Math.max(
...data.daily_activity.map((item) => item.searches + item.ai_questions),
1,
)
}, [data])
if (loading || !data) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-44 rounded-3xl" />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Skeleton className="h-[520px] rounded-3xl" />
<Skeleton className="h-[520px] rounded-3xl" />
</div>
</div>
)
}
const statCards = [
{
label: '累计搜索',
value: String(data.overview.total_searches),
note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)}`,
icon: Search,
},
{
label: '累计 AI 提问',
value: String(data.overview.total_ai_questions),
note: `近 7 天 ${data.overview.ai_questions_last_7d}`,
icon: BrainCircuit,
},
{
label: '24 小时活跃',
value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h),
note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`,
icon: Clock3,
},
{
label: '近 7 天去重词',
value: String(
data.overview.unique_search_terms_last_7d +
data.overview.unique_ai_questions_last_7d,
),
note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`,
icon: BarChart3,
},
]
const contentStatCards = [
{
label: '累计页面访问',
value: String(data.content_overview.total_page_views),
note: `近 24 小时 ${data.content_overview.page_views_last_24h} 次,近 7 天 ${data.content_overview.page_views_last_7d}`,
icon: Eye,
},
{
label: '累计完读次数',
value: String(data.content_overview.total_read_completes),
note: `近 7 天新增 ${data.content_overview.read_completes_last_7d} 次 read_complete`,
icon: BarChart3,
},
{
label: '近 7 天平均进度',
value: formatPercent(data.content_overview.avg_read_progress_last_7d),
note: '基于 read_progress / read_complete 事件估算内容消费深度',
icon: Search,
},
{
label: '近 7 天平均阅读时长',
value: formatDuration(data.content_overview.avg_read_duration_ms_last_7d),
note: '同一会话在文章页停留并产生阅读进度的平均时长',
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">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI 访便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<BrainCircuit className="h-4 w-4" />
</a>
</Button>
<Button
variant="secondary"
onClick={() => void loadAnalytics(true)}
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{statCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{contentStatCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<Card className="border-primary/15 bg-gradient-to-br from-primary/5 via-card to-card">
<CardContent className="flex flex-col gap-5 pt-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">AI </Badge>
<span className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</span>
</div>
<div className="text-3xl font-semibold tracking-tight">
{data.ai_discovery_page_views_last_7d}
</div>
<p className="text-sm leading-6 text-muted-foreground">
ChatGPT SearchPerplexityCopilot/BingGeminiClaude
AI / 访
</p>
</div>
<div className="grid gap-3 sm:min-w-72 sm:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
page_view
</p>
<p className="mt-3 text-2xl font-semibold">{formatPercent(aiDiscoveryShare)}</p>
<p className="mt-2 text-sm text-muted-foreground">
page_view{data.content_overview.page_views_last_7d}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/75 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<p className="mt-3 text-2xl font-semibold">
{aiDiscoveryTopSource ? formatReferrerLabel(aiDiscoveryTopSource.referrer) : '暂无'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{aiDiscoveryTopSource ? `${aiDiscoveryTopSource.count} 次访问` : '等待来源数据'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI
</CardTitle>
<CardDescription>
便 GEO AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.ai_referrers_last_7d.length ? (
data.ai_referrers_last_7d.map((item) => {
const width = `${
Math.max(
(item.count / Math.max(data.ai_discovery_page_views_last_7d, 1)) * 100,
8,
)
}%`
return (
<div key={item.referrer} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="font-medium">{formatReferrerLabel(item.referrer)}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width }}
/>
</div>
</div>
)
})
) : (
<p className="text-sm text-muted-foreground">
7 AI
</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
AI
</CardDescription>
</div>
<Badge variant="outline">{data.recent_events.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recent_events.map((event) => (
<TableRow key={event.id}>
<TableCell>
<div className="space-y-1">
<Badge variant={event.event_type === 'ai_question' ? 'secondary' : 'outline'}>
{formatEventType(event.event_type)}
</Badge>
{event.response_mode ? (
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{event.response_mode}
</p>
) : null}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<p className="line-clamp-2 font-medium">{event.query}</p>
<p className="text-xs text-muted-foreground">
{event.provider ? `${event.provider}` : '未记录渠道'}
{event.chat_model ? ` / ${event.chat_model}` : ''}
</p>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div>{formatSuccess(event.success)}</div>
<div className="mt-1">
{event.result_count !== null ? `${event.result_count} 条/源` : '无'}
</div>
{event.latency_ms !== null ? (
<div className="mt-1 text-xs uppercase tracking-[0.16em]">
{event.latency_ms} ms
</div>
) : null}
</TableCell>
<TableCell className="text-muted-foreground">{event.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
page_view / read_complete
</CardDescription>
</div>
<Badge variant="outline">{data.popular_posts.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.popular_posts.length ? (
data.popular_posts.map((post) => (
<TableRow key={post.slug}>
<TableCell>
<div className="space-y-1">
<a
href={buildFrontendUrl(`/articles/${post.slug}`)}
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
{post.title}
</a>
<p className="font-mono text-xs text-muted-foreground">
{post.slug}
</p>
</div>
</TableCell>
<TableCell>{post.page_views}</TableCell>
<TableCell>{post.read_completes}</TableCell>
<TableCell>{formatPercent(post.avg_progress_percent)}</TableCell>
<TableCell>{formatDuration(post.avg_duration_ms)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-sm text-muted-foreground">
访
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_search_terms.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_search_terms.length ? (
data.top_search_terms.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle> AI </CardTitle>
<CardDescription>
7
</CardDescription>
</div>
<Badge variant="outline">{data.top_ai_questions.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.top_ai_questions.length ? (
data.top_ai_questions.map((item) => (
<div
key={`${item.query}-${item.last_seen_at}`}
className="rounded-2xl border border-border/70 bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{item.query}</p>
<Badge variant="secondary">{item.count}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{item.last_seen_at}
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
</div>
</div>
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24 访
</p>
<p className="mt-3 text-3xl font-semibold">
{data.content_overview.page_views_last_24h}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{data.content_overview.read_completes_last_7d}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{formatPercent(data.content_overview.avg_read_progress_last_7d)}
</p>
<p className="mt-2 text-sm text-muted-foreground"> 7 </p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{formatDuration(data.content_overview.avg_read_duration_ms_last_7d)}
</p>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 page_view referrer host
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.top_referrers.length ? (
data.top_referrers.map((item) => (
<div
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>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 AI 使 provider
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.providers_last_7d.length ? (
data.providers_last_7d.map((item) => (
<div
key={item.provider}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="font-medium">{item.provider}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 AI </p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>7 </CardTitle>
<CardDescription>
AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{data.daily_activity.map((item) => {
const total = item.searches + item.ai_questions
const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%`
return (
<div key={item.date} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{item.date}</span>
<span className="text-muted-foreground">
{item.searches} / AI {item.ai_questions}
</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>
)
})}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { RefreshCcw } 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 { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { AuditLogRecord } from '@/lib/types'
export function AuditPage() {
const [logs, setLogs] = useState<AuditLogRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [keyword, setKeyword] = useState('')
const loadLogs = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listAuditLogs({ limit: 120 })
startTransition(() => {
setLogs(next)
})
if (showToast) {
toast.success('审计日志已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载审计日志。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadLogs(false)
}, [loadLogs])
const filteredLogs = useMemo(() => {
const normalized = keyword.trim().toLowerCase()
if (!normalized) {
return logs
}
return logs.filter((log) =>
[
log.action,
log.target_type,
log.target_id ?? '',
log.target_label ?? '',
log.actor_username ?? '',
log.actor_email ?? '',
]
.join(' ')
.toLowerCase()
.includes(normalized),
)
}, [keyword, logs])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 rounded-3xl" />
<Skeleton className="h-[520px] 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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="按动作 / 对象 / 操作者过滤"
className="w-[280px]"
/>
<Button variant="secondary" onClick={() => void loadLogs(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> 120 </CardDescription>
</div>
<Badge variant="outline">{filteredLogs.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-muted-foreground">{log.created_at}</TableCell>
<TableCell>
<Badge variant="secondary">{log.action}</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{log.target_type}</div>
<div className="font-mono text-xs text-muted-foreground">
{log.target_label ?? log.target_id ?? '—'}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{log.actor_username ?? 'system'}</div>
<div className="text-xs text-muted-foreground">
{log.actor_email ?? log.actor_source ?? '未记录'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[320px] text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-5">
{log.metadata ? JSON.stringify(log.metadata, null, 2) : '—'}
</pre>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { Download, RefreshCcw, Upload } from 'lucide-react'
import { 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 { adminApi, ApiError } from '@/lib/api'
import type { SiteBackupDocument, SiteBackupImportResponse } from '@/lib/types'
function downloadJson(filename: string, payload: unknown) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
export function BackupsPage() {
const [exporting, setExporting] = useState(false)
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState<'merge' | 'replace'>('merge')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [selectedBackup, setSelectedBackup] = useState<SiteBackupDocument | null>(null)
const [lastImportResult, setLastImportResult] = useState<SiteBackupImportResponse | null>(null)
const backupStats = useMemo(() => {
if (!selectedBackup) {
return null
}
return {
posts: selectedBackup.posts.length,
categories: selectedBackup.categories.length,
tags: selectedBackup.tags.length,
reviews: selectedBackup.reviews.length,
friendLinks: selectedBackup.friend_links.length,
mediaAssets: selectedBackup.media_assets.length,
storageManifest: selectedBackup.storage_manifest?.length ?? 0,
}
}, [selectedBackup])
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"> / </Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
/
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-3xl border border-border/70 bg-background/50 p-4 text-sm leading-7 text-muted-foreground">
<p></p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li></li>
<li>Markdown </li>
<li> / </li>
<li></li>
<li></li>
</ul>
</div>
<Button
disabled={exporting}
onClick={async () => {
try {
setExporting(true)
const backup = await adminApi.exportSiteBackup()
const exportedAt = backup.exported_at.replaceAll(':', '-').replaceAll('.', '-')
downloadJson(`termi-backup-${exportedAt}.json`, backup)
toast.success('备份已导出到本地。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '导出备份失败。')
} finally {
setExporting(false)
}
}}
>
<Download className="h-4 w-4" />
{exporting ? '导出中...' : '下载备份 JSON'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
merge / replace replace markdown
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)]">
<Select
value={importMode}
onChange={(event) => setImportMode(event.target.value as 'merge' | 'replace')}
>
<option value="merge">merge</option>
<option value="replace">replace</option>
</Select>
<Input
type="file"
accept="application/json"
onChange={async (event) => {
const file = event.target.files?.item(0) ?? null
setSelectedFile(file)
setLastImportResult(null)
if (!file) {
setSelectedBackup(null)
return
}
try {
const parsed = JSON.parse(await file.text()) as SiteBackupDocument
setSelectedBackup(parsed)
} catch {
setSelectedBackup(null)
toast.error('备份文件不是合法的 JSON。')
}
}}
/>
</div>
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground"></p>
<ul className="mt-2 list-disc space-y-1 pl-5 leading-6">
<li>replace markdown / / </li>
<li>访</li>
<li></li>
</ul>
</div>
{selectedBackup ? (
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline"> {selectedBackup.version}</Badge>
<Badge variant="outline"> {selectedBackup.exported_at}</Badge>
<Badge variant="secondary">{selectedBackup.includes_storage_binaries ? '包含二进制' : '仅对象清单'}</Badge>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3 text-sm text-muted-foreground">
<div>{backupStats?.posts ?? 0}</div>
<div>{backupStats?.categories ?? 0}</div>
<div>{backupStats?.tags ?? 0}</div>
<div>{backupStats?.reviews ?? 0}</div>
<div>{backupStats?.friendLinks ?? 0}</div>
<div>{backupStats?.mediaAssets ?? 0}</div>
<div>{backupStats?.storageManifest ?? 0}</div>
</div>
<p className="mt-4 text-sm leading-6 text-muted-foreground">{selectedBackup.warning}</p>
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-8 text-center text-sm text-muted-foreground">
{selectedFile ? '当前文件未通过 JSON 校验。' : '选择一个备份 JSON 后,这里会显示导入概览。'}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={!selectedBackup || importing}
variant={importMode === 'replace' ? 'danger' : 'default'}
onClick={async () => {
if (!selectedBackup) {
return
}
if (
importMode === 'replace' &&
!window.confirm('replace 会覆盖当前内容,确认继续吗?')
) {
return
}
try {
setImporting(true)
const result = await adminApi.importSiteBackup({
backup: selectedBackup,
mode: importMode,
})
setLastImportResult(result)
toast.success('备份已导入。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '导入备份失败。')
} finally {
setImporting(false)
}
}}
>
<Upload className="h-4 w-4" />
{importing ? '导入中...' : importMode === 'replace' ? '执行覆盖恢复' : '执行合并恢复'}
</Button>
<Button
variant="outline"
onClick={() => {
setSelectedFile(null)
setSelectedBackup(null)
setLastImportResult(null)
}}
>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
{lastImportResult ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{lastImportResult.mode}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4 text-sm text-muted-foreground">
<div>{lastImportResult.site_settings_restored ? '已恢复' : '未恢复'}</div>
<div>{lastImportResult.posts_written}</div>
<div>{lastImportResult.categories_upserted}</div>
<div>{lastImportResult.tags_upserted}</div>
<div>{lastImportResult.reviews_upserted}</div>
<div>{lastImportResult.friend_links_upserted}</div>
<div>{lastImportResult.media_assets_upserted}</div>
<div>{lastImportResult.storage_manifest_items}</div>
<div className="sm:col-span-2 xl:col-span-4">{lastImportResult.warning}</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -0,0 +1,420 @@
import { Folders, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
import type { CategoryRecord, TaxonomyPayload } from '@/lib/types'
type CategoryFormState = {
name: string
slug: string
description: string
coverImage: string
accentColor: string
seoTitle: string
seoDescription: string
}
const defaultCategoryForm: CategoryFormState = {
name: '',
slug: '',
description: '',
coverImage: '',
accentColor: '',
seoTitle: '',
seoDescription: '',
}
function toFormState(item: CategoryRecord): CategoryFormState {
return {
name: item.name,
slug: item.slug,
description: item.description ?? '',
coverImage: item.cover_image ?? '',
accentColor: item.accent_color ?? '',
seoTitle: item.seo_title ?? '',
seoDescription: item.seo_description ?? '',
}
}
function toPayload(form: CategoryFormState): TaxonomyPayload {
return {
name: form.name.trim(),
slug: emptyToNull(form.slug),
description: emptyToNull(form.description),
coverImage: emptyToNull(form.coverImage),
accentColor: emptyToNull(form.accentColor),
seoTitle: emptyToNull(form.seoTitle),
seoDescription: emptyToNull(form.seoDescription),
}
}
export function CategoriesPage() {
const [items, setItems] = useState<CategoryRecord[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [form, setForm] = useState<CategoryFormState>(defaultCategoryForm)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const loadCategories = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listCategories()
startTransition(() => {
setItems(next)
})
if (showToast) {
toast.success('分类列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载分类列表。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadCategories(false)
}, [loadCategories])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[item.name, item.slug, item.description ?? '', item.seo_title ?? '']
.join('\n')
.toLowerCase()
.includes(keyword),
)
}, [items, searchTerm])
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId],
)
const resetForm = useCallback(() => {
setSelectedId(null)
setForm(defaultCategoryForm)
}, [])
const handleSave = useCallback(async () => {
if (!form.name.trim()) {
toast.error('请先填写分类名称。')
return
}
try {
setSaving(true)
if (selectedId) {
const updated = await adminApi.updateCategory(selectedId, toPayload(form))
startTransition(() => {
setItems((current) => current.map((item) => (item.id === updated.id ? updated : item)))
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('分类已更新。')
} else {
const created = await adminApi.createCategory(toPayload(form))
startTransition(() => {
setItems((current) => [created, ...current])
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('分类已创建。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '保存分类失败。')
} finally {
setSaving(false)
}
}, [form, selectedId])
const handleDelete = useCallback(async () => {
if (!selectedItem) {
return
}
if (!window.confirm(`确认删除分类「${selectedItem.name}」吗?相关文章会同步移除该分类引用。`)) {
return
}
try {
setDeleting(true)
await adminApi.deleteCategory(selectedItem.id)
startTransition(() => {
setItems((current) => current.filter((item) => item.id !== selectedItem.id))
})
toast.success('分类已删除。')
resetForm()
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除分类失败。')
} finally {
setDeleting(false)
}
}, [resetForm, selectedItem])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[720px] 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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
SEO
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={resetForm}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => void loadCategories(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>slug SEO </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
data-testid="categories-search"
placeholder="按分类名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
{filteredItems.length ? (
<div className="space-y-3">
{filteredItems.map((item) => (
<button
key={item.id}
type="button"
data-testid={`category-item-${item.slug}`}
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === item.id
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.name}</span>
<Badge variant="outline">{item.slug}</Badge>
{item.accent_color ? (
<span
className="inline-flex h-5 w-5 rounded-full border border-border/80"
style={{ backgroundColor: item.accent_color }}
/>
) : null}
</div>
<p className="text-sm text-muted-foreground">
{item.description || `${item.count} 篇文章正在使用这个分类`}
</p>
</div>
<Badge variant={item.count > 0 ? 'success' : 'secondary'}>{item.count}</Badge>
</div>
</button>
))}
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Folders className="h-5 w-5" />
</div>
<div>
<CardTitle>{selectedItem ? '编辑分类' : '新建分类'}</CardTitle>
<CardDescription>
/ slug SEO
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<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="输入分类名称"
/>
</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>
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
<div className="flex items-center gap-3">
<Input
value={form.accentColor}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
placeholder="#3b82f6"
/>
<input
type="color"
value={form.accentColor || '#2563eb'}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
className="h-10 w-14 rounded-xl border border-input bg-background px-1"
/>
</div>
</FormField>
</div>
<FormField label="分类描述" hint="会展示在前台分类卡片和分类详情区域。">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={4}
placeholder="介绍这个分类主要收录哪些内容。"
/>
</FormField>
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="SEO 标题" hint="留空时前台继续回退到常规标题。">
<Input
value={form.seoTitle}
onChange={(event) =>
setForm((current) => ({ ...current, seoTitle: event.target.value }))
}
placeholder="前端工程专题 - Termi"
/>
</FormField>
<FormField label="SEO 描述" hint="搜索引擎摘要或社交分享描述。">
<Textarea
value={form.seoDescription}
onChange={(event) =>
setForm((current) => ({ ...current, seoDescription: event.target.value }))
}
rows={4}
placeholder="这个分类汇总了工程化、构建链路与调优经验。"
/>
</FormField>
</div>
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/50 p-4 md:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-2xl font-semibold text-foreground">{selectedItem?.count ?? 0}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.created_at)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.updated_at)}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving} data-testid="category-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
</Button>
<Button variant="outline" onClick={resetForm}>
</Button>
<Button
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" />
{deleting ? '删除中...' : '删除分类'}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -8,16 +8,23 @@ import { Label } from '@/components/ui/label'
export function LoginPage({
submitting,
localLoginEnabled,
proxyAuthEnabled,
onLogin,
}: {
submitting: boolean
localLoginEnabled: boolean
proxyAuthEnabled: boolean
onLogin: (payload: { username: string; password: string }) => Promise<void>
}) {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('admin123')
return (
<div className="flex min-h-screen items-center justify-center px-4 py-10">
<main
className="flex min-h-screen items-center justify-center px-4 py-10"
aria-labelledby="admin-login-title"
>
<div className="grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<Card className="overflow-hidden border-primary/12 bg-gradient-to-br from-card via-card to-primary/5">
<CardHeader className="space-y-4">
@@ -30,7 +37,7 @@ export function LoginPage({
线
</CardTitle>
<CardDescription className="max-w-xl text-base leading-7">
AI
API
</CardDescription>
</div>
</CardHeader>
@@ -53,54 +60,68 @@ export function LoginPage({
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<CardTitle id="admin-login-title" className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LockKeyhole className="h-5 w-5" />
</span>
</CardTitle>
<CardDescription>
{localLoginEnabled
? '当前登录复用后端管理员账号;如果前面接了 TinyAuth / Pocket ID也可以直接由反向代理完成 SSO。'
: proxyAuthEnabled
? '当前后台已切到代理侧 SSO 模式,请从受保护的后台域名入口进入。'
: '当前后台未开放本地账号密码登录,请检查部署配置。'}
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault()
void onLogin({ username, password })
}}
>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</div>
{localLoginEnabled ? (
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault()
void onLogin({ username, password })
}}
>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
) : (
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-7 text-muted-foreground">
<p> Caddy + TinyAuth + Pocket ID </p>
<p> SSO </p>
<Button className="w-full" size="lg" onClick={() => window.location.reload()}>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,901 @@
import {
CheckSquare,
Copy,
Download,
Image as ImageIcon,
RefreshCcw,
Replace,
Save,
Square,
Trash2,
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'
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 { adminApi, ApiError } from '@/lib/api'
import {
formatCompressionPreview,
prepareImageForUpload,
type MediaUploadTargetFormat,
} from '@/lib/image-compress'
import type { AdminMediaObjectResponse } from '@/lib/types'
import { FormField } from '@/components/form-field'
import { Textarea } from '@/components/ui/textarea'
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
}
type MediaMetadataFormState = {
title: string
altText: string
caption: string
tags: string
notes: string
}
const defaultMetadataForm: MediaMetadataFormState = {
title: '',
altText: '',
caption: '',
tags: '',
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
}
return {
title: item.title ?? '',
altText: item.alt_text ?? '',
caption: item.caption ?? '',
tags: normalizeMediaTags(item.tags).join(', '),
notes: item.notes ?? '',
}
}
function parseTagList(value: string) {
return Array.from(
new Set(
value
.split(',')
.map((item) => item.trim())
.filter(Boolean),
),
)
}
export function MediaPage() {
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
const [replacingKey, setReplacingKey] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
const [prefixFilter, setPrefixFilter] = useState('all')
const [uploadPrefix, setUploadPrefix] = useState('post-covers/')
const [searchTerm, setSearchTerm] = useState('')
const [provider, setProvider] = useState<string | null>(null)
const [bucket, setBucket] = useState<string | null>(null)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
const [activeKey, setActiveKey] = useState<string | null>(null)
const [metadataForm, setMetadataForm] = useState<MediaMetadataFormState>(defaultMetadataForm)
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 {
if (showToast) {
setRefreshing(true)
}
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
const normalizedItems = result.items.map(normalizeMediaItem)
startTransition(() => {
setItems(normalizedItems)
setProvider(result.provider)
setBucket(result.bucket)
})
if (showToast) {
toast.success('媒体对象列表已刷新。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [prefixFilter])
useEffect(() => {
void loadItems(false)
}, [loadItems])
useEffect(() => {
setSelectedKeys((current) =>
current.filter((key) => items.some((item) => item.key === key)),
)
}, [items])
useEffect(() => {
if (!items.length) {
setActiveKey(null)
setMetadataForm(defaultMetadataForm)
return
}
setActiveKey((current) => (current && items.some((item) => item.key === current) ? current : items[0].key))
}, [items])
const activeItem = useMemo(
() => items.find((item) => item.key === activeKey) ?? null,
[activeKey, items],
)
useEffect(() => {
setMetadataForm(toMetadataForm(activeItem))
}, [activeItem])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) => item.key.toLowerCase().includes(keyword))
}, [items, searchTerm])
const allFilteredSelected =
filteredItems.length > 0 && filteredItems.every((item) => selectedKeys.includes(item.key))
async function prepareFiles(files: File[], targetPrefix = uploadPrefix) {
if (!compressBeforeUpload) {
return files
}
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 result: File[] = []
for (const file of files) {
const compressed = await prepareImageForUpload(file, {
compress: true,
quality: safeQuality,
targetFormat: uploadTargetFormat,
contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}${file.name}`,
mode,
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
}
result.push(compressed.file)
}
return result
}
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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadItems(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
data-testid="media-batch-delete"
onClick={async () => {
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
return
}
try {
setBatchDeleting(true)
const result = await adminApi.batchDeleteMediaObjects(selectedKeys)
if (result.failed.length) {
toast.warning(`已删除 ${result.deleted.length} 个,失败 ${result.failed.length} 个。`)
} else {
toast.success(`已删除 ${result.deleted.length} 个对象。`)
}
await loadItems(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '批量删除失败。')
} finally {
setBatchDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
({selectedKeys.length})
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
Provider{provider ?? '未配置'} / Bucket{bucket ?? '未配置'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 lg:grid-cols-[220px_220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<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]">
<Input
data-testid="media-upload-input"
type="file"
multiple
accept="image/*"
onChange={(event) => {
const files = Array.from(event.target.files || [])
setUploadFiles(files)
}}
/>
<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}
>
<option value="avif"> AVIF</option>
<option value="webp"> WebP</option>
<option value="auto"></option>
</Select>
<Input
className="w-[96px]"
value={compressQuality}
onChange={(event) => setCompressQuality(event.target.value)}
placeholder="0.82"
disabled={!compressBeforeUpload}
/>
<Button
disabled={!uploadFiles.length || uploading}
data-testid="media-upload"
onClick={async () => {
try {
setUploading(true)
const files = await prepareFiles(uploadFiles)
const result = await adminApi.uploadMediaObjects(files, {
prefix: uploadPrefix,
})
toast.success(`上传完成,共 ${result.uploaded.length} 个文件。`)
setUploadFiles([])
await loadItems(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '上传失败。')
} finally {
setUploading(false)
}
}}
>
<Upload className="h-4 w-4" />
{uploading ? '上传中...' : '上传'}
</Button>
</div>
{uploadFiles.length ? (
<p className="text-xs text-muted-foreground">
{uploadFiles.length}
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。'
: ''}
</p>
) : null}
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<p className="text-xs leading-6 text-muted-foreground">
访 / PDF worker
</p>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<FormField label="远程 URL">
<Input
data-testid="media-remote-url"
value={remoteDownloadForm.sourceUrl}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
sourceUrl: event.target.value,
}))
}
placeholder="https://example.com/cover.webp"
/>
</FormField>
<FormField label="抓取格式">
<Select
value={remoteTargetFormat}
onChange={(event) =>
setRemoteTargetFormat(event.target.value as 'original' | 'webp' | 'avif')
}
>
<option value="original"></option>
<option value="webp"> WebP</option>
<option value="avif"> AVIF</option>
</Select>
</FormField>
<FormField label="标题">
<Input
data-testid="media-remote-title"
value={remoteDownloadForm.title}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
title: event.target.value,
}))
}
placeholder="远程抓取封面"
/>
</FormField>
<FormField label="Alt 文本">
<Input
value={remoteDownloadForm.altText}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
altText: event.target.value,
}))
}
placeholder="终端风格封面图"
/>
</FormField>
<FormField label="标签">
<Input
value={remoteDownloadForm.tags}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
tags: event.target.value,
}))
}
placeholder="remote, cover"
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Caption">
<Textarea
value={remoteDownloadForm.caption}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
caption: event.target.value,
}))
}
rows={3}
placeholder="适合记录图片用途或展示位置。"
/>
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="内部备注">
<Textarea
value={remoteDownloadForm.notes}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
notes: event.target.value,
}))
}
rows={3}
placeholder="可选:记录素材来源、版权说明等。"
/>
</FormField>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Button
data-testid="media-remote-download"
disabled={!remoteDownloadForm.sourceUrl.trim() || downloadingRemote}
onClick={async () => {
if (!remoteDownloadForm.sourceUrl.trim()) {
toast.error('请先填写远程 URL。')
return
}
try {
setDownloadingRemote(true)
const result = await adminApi.downloadMediaObject({
sourceUrl: remoteDownloadForm.sourceUrl.trim(),
prefix: uploadPrefix,
targetFormat: remoteTargetFormat,
title: remoteDownloadForm.title.trim() || null,
altText: remoteDownloadForm.altText.trim() || null,
caption: remoteDownloadForm.caption.trim() || null,
tags: parseTagList(remoteDownloadForm.tags),
notes: remoteDownloadForm.notes.trim() || null,
})
setLastRemoteDownloadJobId(result.job_id)
toast.success(
result.job_id
? `远程抓取任务已入队:#${result.job_id}`
: '远程抓取请求已提交。',
)
setRemoteDownloadForm(defaultRemoteDownloadForm)
window.setTimeout(() => {
void loadItems(false)
}, 1500)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '远程抓取失败。')
} finally {
setDownloadingRemote(false)
}
}}
>
<Download className="h-4 w-4" />
{downloadingRemote ? '抓取中...' : '抓取远程素材'}
</Button>
{lastRemoteDownloadJobId ? (
<Button variant="outline" asChild data-testid="media-last-remote-job">
<Link to={`/workers?job=${lastRemoteDownloadJobId}`}></Link>
</Button>
) : null}
</div>
</div>
</CardContent>
</Card>
{activeItem ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{activeItem.key}alt /
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<div className="space-y-4">
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="标题" hint="媒体资源的人类可读名称。">
<Input
value={metadataForm.title}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, title: event.target.value }))
}
placeholder="文章封面 / 站点横幅"
/>
</FormField>
<FormField label="Alt 文本" hint="用于 img alt 和无障碍描述。">
<Input
value={metadataForm.altText}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, altText: event.target.value }))
}
placeholder="夜色下的终端风格博客封面"
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签用英文逗号分隔。">
<Input
value={metadataForm.tags}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, tags: event.target.value }))
}
placeholder="cover, astro, terminal"
/>
</FormField>
<FormField label="Caption" hint="适合前台图注、图片说明。">
<Textarea
value={metadataForm.caption}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, caption: event.target.value }))
}
rows={4}
placeholder="这张图通常用于文章列表和详情页头图。"
/>
</FormField>
<FormField label="内部备注" hint="仅后台使用,例如素材来源、版权或推荐用途。">
<Textarea
value={metadataForm.notes}
onChange={(event) =>
setMetadataForm((current) => ({ ...current, notes: event.target.value }))
}
rows={4}
placeholder="来源Unsplash / 站点截图 / AI 生成"
/>
</FormField>
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={metadataSaving}
data-testid="media-save-metadata"
onClick={async () => {
if (!activeItem) {
return
}
try {
setMetadataSaving(true)
const result = await adminApi.updateMediaObjectMetadata({
key: activeItem.key,
title: metadataForm.title || null,
altText: metadataForm.altText || null,
caption: metadataForm.caption || null,
tags: parseTagList(metadataForm.tags),
notes: metadataForm.notes || null,
})
startTransition(() => {
setItems((current) =>
current.map((item) =>
item.key === result.key
? {
...item,
title: result.title,
alt_text: result.alt_text,
caption: result.caption,
tags: normalizeMediaTags(result.tags),
notes: result.notes,
}
: item,
),
)
})
toast.success('媒体元数据已保存。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '保存媒体元数据失败。')
} finally {
setMetadataSaving(false)
}
}}
>
<Save className="h-4 w-4" />
{metadataSaving ? '保存中...' : '保存元数据'}
</Button>
<Button variant="outline" onClick={() => setMetadataForm(toMetadataForm(activeItem))}>
</Button>
</div>
</div>
<div className="space-y-4 rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="aspect-[16/9] overflow-hidden rounded-2xl border border-border/70 bg-muted/30">
<img
src={activeItem.url}
alt={metadataForm.altText || activeItem.key}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<p className="break-all font-medium text-foreground">{activeItem.key}</p>
<p>{formatBytes(activeItem.size_bytes)} · {activeItem.last_modified ?? '未知修改时间'}</p>
<p>{metadataForm.altText || '尚未填写 alt 文本'}</p>
</div>
</div>
</div>
</CardContent>
</Card>
) : null}
{loading ? (
<Skeleton className="h-[520px] rounded-3xl" />
) : (
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{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">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
<button
type="button"
className="absolute left-2 top-2 rounded-xl border border-border/80 bg-background/80 p-1"
onClick={() => {
setSelectedKeys((current) => {
if (current.includes(item.key)) {
return current.filter((key) => key !== item.key)
}
return [...current, item.key]
})
}}
>
{selected ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
{itemTags.length ? (
<div className="flex flex-wrap gap-2">
{itemTags.slice(0, 4).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setActiveKey(item.key)}
data-testid={`media-edit-${index}`}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" asChild>
<label htmlFor={replaceInputId} className="cursor-pointer">
<Replace className="h-4 w-4" />
</label>
</Button>
<input
id={replaceInputId}
data-testid={`media-replace-input-${index}`}
className="hidden"
type="file"
accept="image/*"
onChange={async (event) => {
const file = event.target.files?.item(0)
event.currentTarget.value = ''
if (!file) {
return
}
try {
setReplacingKey(item.key)
const [prepared] = await prepareFiles(
[file],
item.key.startsWith('review-covers/')
? 'review-covers/'
: item.key.startsWith('post-covers/')
? 'post-covers/'
: 'uploads/',
)
const result = await adminApi.replaceMediaObject(item.key, prepared)
startTransition(() => {
setItems((current) =>
current.map((currentItem) =>
currentItem.key === item.key
? { ...currentItem, url: result.url }
: currentItem,
),
)
})
toast.success('已替换媒体对象。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '替换失败。')
} finally {
setReplacingKey(null)
}
}}
/>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key || replacingKey === item.key}
data-testid={`media-delete-${index}`}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) =>
current.filter((currentItem) => currentItem.key !== item.key),
)
setSelectedKeys((current) =>
current.filter((key) => key !== item.key),
)
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key
? '删除中...'
: replacingKey === item.key
? '替换中...'
: '删除'}
</Button>
</div>
</CardContent>
</Card>
)
})}
{!filteredItems.length ? (
<Card className="xl:col-span-2 2xl:col-span-3">
<CardContent className="flex flex-col items-center gap-3 px-6 py-16 text-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
<p></p>
</CardContent>
</Card>
) : null}
</div>
)}
{filteredItems.length ? (
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6 text-sm text-muted-foreground">
<p>
{filteredItems.length} {selectedKeys.length}
</p>
<Button
variant="outline"
onClick={() => {
if (allFilteredSelected) {
setSelectedKeys((current) =>
current.filter(
(key) => !filteredItems.some((item) => item.key === key),
),
)
return
}
setSelectedKeys((current) => {
const next = new Set(current)
filteredItems.forEach((item) => next.add(item.key))
return Array.from(next)
})
}}
>
{allFilteredSelected ? <Square className="h-4 w-4" /> : <CheckSquare className="h-4 w-4" />}
{allFilteredSelected ? '取消全选' : '全选当前筛选'}
</Button>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

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

View File

@@ -1,4 +1,3 @@
import { DiffEditor } from '@monaco-editor/react'
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -8,6 +7,7 @@ import {
editorTheme,
sharedOptions,
} from '@/components/markdown-workbench'
import { LazyDiffEditor } from '@/components/lazy-monaco'
import { MarkdownPreview } from '@/components/markdown-preview'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -177,7 +177,7 @@ export function PostPolishPage() {
<Card>
<CardHeader>
<CardTitle> vs </CardTitle>
<CardDescription>{snapshot.path}</CardDescription>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
@@ -191,7 +191,7 @@ export function PostPolishPage() {
<span></span>
</div>
<div className="h-[560px]">
<DiffEditor
<LazyDiffEditor
height="100%"
language="markdown"
original={originalMarkdown}

View File

@@ -1,5 +1,6 @@
import { ExternalLink, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { MarkdownPreview } from '@/components/markdown-preview'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
@@ -7,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
type PreviewState = {
@@ -16,15 +18,6 @@ type PreviewState = {
markdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
@@ -34,7 +27,8 @@ function getDraftKey() {
}
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const { slug: routeSlug } = useParams<{ slug?: string }>()
const slug = slugOverride ?? routeSlug ?? ''
const [state, setState] = useState<PreviewState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -49,7 +43,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const draft = loadDraftWindowSnapshot(getDraftKey())
if (draft && draft.slug === slug) {
if (draft && (!slug || draft.slug === slug)) {
if (!active) {
return
}
@@ -65,6 +59,10 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
return
}
if (!slug) {
throw new Error('缺少文章 slug无法加载独立预览。')
}
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),
@@ -124,7 +122,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
</Button>
{slug ? (
<Button variant="outline" asChild>
<a href={`http://localhost:4321/articles/${slug}`} target="_blank" rel="noreferrer">
<a href={buildFrontendUrl(`/articles/${slug}`)} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
import { BookOpenText, RefreshCcw, Save, Trash2 } from 'lucide-react'
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -32,6 +33,11 @@ type ReviewFormState = {
linkUrl: string
}
type ReviewDescriptionPolishState = {
originalDescription: string
polishedDescription: string
}
const defaultReviewForm: ReviewFormState = {
title: '',
reviewType: 'book',
@@ -94,6 +100,10 @@ export function ReviewsPage() {
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [polishingDescription, setPolishingDescription] = useState(false)
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
null,
)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
@@ -153,6 +163,55 @@ export function ReviewsPage() {
[reviews, selectedId],
)
const requestDescriptionPolish = useCallback(async () => {
if (!form.description.trim()) {
toast.error('请先写一点点评内容,再让 AI 帮你润色。')
return
}
try {
setPolishingDescription(true)
const result = await adminApi.polishReviewDescription({
title: form.title.trim() || '未命名评测',
reviewType: form.reviewType,
rating: Number(form.rating) || 0,
reviewDate: form.reviewDate || null,
status: form.status,
tags: csvToList(form.tags),
description: form.description,
})
const polishedDescription =
typeof result.polished_description === 'string' ? result.polished_description : ''
if (!polishedDescription.trim()) {
throw new Error('AI 润色返回为空。')
}
startTransition(() => {
setDescriptionPolish({
originalDescription: form.description,
polishedDescription,
})
})
if (polishedDescription.trim() === form.description.trim()) {
toast.success('AI 已检查这段点评,当前文案已经比较完整。')
} else {
toast.success('AI 已生成一版更顺的点评文案,可以先对比再决定是否采用。')
}
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: error instanceof Error
? error.message
: 'AI 润色点评失败。',
)
} finally {
setPolishingDescription(false)
}
}, [form])
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -172,6 +231,7 @@ export function ReviewsPage() {
onClick={() => {
setSelectedId(null)
setForm(defaultReviewForm)
setDescriptionPolish(null)
}}
>
@@ -217,9 +277,11 @@ export function ReviewsPage() {
<button
key={review.id}
type="button"
data-testid={`review-item-${review.id}`}
onClick={() => {
setSelectedId(review.id)
setForm(toFormState(review))
setDescriptionPolish(null)
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === review.id
@@ -274,6 +336,7 @@ export function ReviewsPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
data-testid="review-save"
onClick={async () => {
if (!form.title.trim()) {
toast.error('标题不能为空。')
@@ -295,6 +358,7 @@ export function ReviewsPage() {
startTransition(() => {
setSelectedId(updated.id)
setForm(toFormState(updated))
setDescriptionPolish(null)
})
toast.success('评测已更新。')
} else {
@@ -302,6 +366,7 @@ export function ReviewsPage() {
startTransition(() => {
setSelectedId(created.id)
setForm(toFormState(created))
setDescriptionPolish(null)
})
toast.success('评测已创建。')
}
@@ -320,6 +385,7 @@ export function ReviewsPage() {
{selectedReview ? (
<Button
variant="danger"
data-testid="review-delete"
disabled={deleting}
onClick={async () => {
if (!window.confirm('确定删除这条评测吗?')) {
@@ -332,6 +398,7 @@ export function ReviewsPage() {
toast.success('评测已删除。')
setSelectedId(null)
setForm(defaultReviewForm)
setDescriptionPolish(null)
await loadReviews(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
@@ -361,6 +428,7 @@ export function ReviewsPage() {
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="标题">
<Input
data-testid="review-title"
value={form.title}
onChange={(event) =>
setForm((current) => ({ ...current, title: event.target.value }))
@@ -395,6 +463,7 @@ export function ReviewsPage() {
</FormField>
<FormField label="评测日期">
<Input
data-testid="review-date"
type="date"
value={form.reviewDate}
onChange={(event) =>
@@ -414,13 +483,34 @@ export function ReviewsPage() {
<option value="archived"></option>
</Select>
</FormField>
<FormField label="封面 URL">
<Input
value={form.cover}
onChange={(event) =>
setForm((current) => ({ ...current, cover: event.target.value }))
}
/>
<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"
/>
{form.cover ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
<img
src={form.cover}
alt={form.title || '评测封面预览'}
className="h-48 w-full object-cover"
/>
</div>
) : null}
</div>
</FormField>
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
<Input
@@ -442,13 +532,116 @@ export function ReviewsPage() {
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="简介">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
<FormField
label="简介 / 点评"
hint="可以先写你的原始观感,再用 AI 帮你把这段点评润得更顺。"
>
<div className="space-y-3">
<div className="flex flex-col gap-3 rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<p className="text-sm leading-6 text-muted-foreground">
AI
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
data-testid="review-ai-polish"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
<Bot className="h-4 w-4" />
{polishingDescription ? '润色中...' : 'AI 润色点评'}
</Button>
{descriptionPolish ? (
<Button
size="sm"
variant="ghost"
onClick={() => setDescriptionPolish(null)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<Textarea
data-testid="review-description"
value={form.description}
onChange={(event) => {
const nextDescription = event.target.value
setForm((current) => ({ ...current, description: nextDescription }))
setDescriptionPolish((current) =>
current && current.originalDescription === nextDescription ? current : null,
)
}}
/>
{descriptionPolish ? (
<div className="overflow-hidden rounded-[1.8rem] border border-border/70 bg-background/80">
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-base font-semibold">AI </p>
<p className="mt-1 text-sm text-muted-foreground">
AI
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
data-testid="review-ai-adopt"
onClick={() => {
setForm((current) => ({
...current,
description: descriptionPolish.polishedDescription,
}))
setDescriptionPolish(null)
toast.success('AI 润色点评已回填到评测简介。')
}}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void requestDescriptionPolish()}
disabled={polishingDescription}
>
<Bot className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDescriptionPolish(null)}
>
</Button>
</div>
</div>
<div className="grid gap-4 p-5 xl:grid-cols-2">
<div className="rounded-[1.4rem] border border-border/70 bg-muted/20 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
</p>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
{descriptionPolish.originalDescription.trim() || '未填写'}
</p>
</div>
<div className="rounded-[1.4rem] border border-emerald-500/30 bg-emerald-500/5 p-4">
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
AI
</p>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
{descriptionPolish.polishedDescription.trim() || '未填写'}
</p>
</div>
</div>
</div>
) : null}
</div>
</FormField>
</div>
</div>

View File

@@ -0,0 +1,427 @@
import { ArrowLeftRight, History, RefreshCcw, RotateCcw } 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 { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { countLineDiff } from '@/lib/markdown-diff'
import { parseMarkdownDocument } from '@/lib/markdown-document'
import type { PostRevisionDetail, PostRevisionRecord } from '@/lib/types'
type RestoreMode = 'full' | 'markdown' | 'metadata'
const META_LABELS: Record<string, string> = {
title: '标题',
slug: 'Slug',
description: '摘要',
category: '分类',
postType: '类型',
image: '封面',
images: '图片集',
pinned: '置顶',
status: '状态',
visibility: '可见性',
publishAt: '定时发布',
unpublishAt: '下线时间',
canonicalUrl: 'Canonical',
noindex: 'Noindex',
ogImage: 'OG 图',
redirectFrom: '旧地址',
redirectTo: '重定向',
tags: '标签',
}
function stableValue(value: unknown) {
if (Array.isArray(value) || (value && typeof value === 'object')) {
return JSON.stringify(value)
}
return String(value ?? '')
}
function summarizeMetadataChanges(leftMarkdown: string, rightMarkdown: string) {
const left = parseMarkdownDocument(leftMarkdown).meta
const right = parseMarkdownDocument(rightMarkdown).meta
return Object.entries(META_LABELS)
.filter(([key]) => stableValue(left[key as keyof typeof left]) !== stableValue(right[key as keyof typeof right]))
.map(([, label]) => label)
}
export function RevisionsPage() {
const [revisions, setRevisions] = useState<PostRevisionRecord[]>([])
const [selected, setSelected] = useState<PostRevisionDetail | null>(null)
const [detailsCache, setDetailsCache] = useState<Record<number, PostRevisionDetail>>({})
const [liveMarkdown, setLiveMarkdown] = useState('')
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [slugFilter, setSlugFilter] = useState('')
const [compareTarget, setCompareTarget] = useState('current')
const loadRevisions = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listPostRevisions({
slug: slugFilter.trim() || undefined,
limit: 120,
})
startTransition(() => {
setRevisions(next)
})
if (showToast) {
toast.success('版本历史已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载版本历史。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [slugFilter])
useEffect(() => {
void loadRevisions(false)
}, [loadRevisions])
const openDetail = useCallback(async (id: number) => {
try {
const detail = detailsCache[id] ?? (await adminApi.getPostRevision(id))
let liveMarkdownValue = ''
try {
const live = await adminApi.getPostMarkdown(detail.item.post_slug)
liveMarkdownValue = live.markdown
} catch {
liveMarkdownValue = ''
}
startTransition(() => {
setDetailsCache((current) => ({ ...current, [id]: detail }))
setSelected(detail)
setLiveMarkdown(liveMarkdownValue)
setCompareTarget('current')
})
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法加载该版本详情。')
}
}, [detailsCache])
useEffect(() => {
if (!selected) {
return
}
if (compareTarget === 'current' || !compareTarget) {
return
}
const revisionId = Number(compareTarget)
if (!Number.isFinite(revisionId) || detailsCache[revisionId]) {
return
}
void adminApi
.getPostRevision(revisionId)
.then((detail) => {
startTransition(() => {
setDetailsCache((current) => ({ ...current, [revisionId]: detail }))
})
})
.catch((error) => {
toast.error(error instanceof ApiError ? error.message : '无法加载比较版本。')
})
}, [compareTarget, detailsCache, selected])
const summary = useMemo(() => {
const uniqueSlugs = new Set(revisions.map((item) => item.post_slug))
return {
count: revisions.length,
slugs: uniqueSlugs.size,
}
}, [revisions])
const compareCandidates = useMemo(
() =>
selected
? revisions.filter((item) => item.post_slug === selected.item.post_slug && item.id !== selected.item.id)
: [],
[revisions, selected],
)
const comparisonMarkdown = useMemo(() => {
if (!selected) {
return ''
}
if (compareTarget === 'current') {
return liveMarkdown
}
const revisionId = Number(compareTarget)
return Number.isFinite(revisionId) ? detailsCache[revisionId]?.markdown ?? '' : ''
}, [compareTarget, detailsCache, liveMarkdown, selected])
const comparisonLabel = useMemo(() => {
if (compareTarget === 'current') {
return '当前线上版本'
}
const revisionId = Number(compareTarget)
const detail = Number.isFinite(revisionId) ? detailsCache[revisionId] : undefined
return detail ? `版本 #${detail.item.id}` : '比较版本'
}, [compareTarget, detailsCache])
const diffStats = useMemo(() => {
if (!selected || !comparisonMarkdown) {
return { additions: 0, deletions: 0 }
}
return countLineDiff(comparisonMarkdown, selected.markdown ?? '')
}, [comparisonMarkdown, selected])
const metadataChanges = useMemo(() => {
if (!selected || !comparisonMarkdown) {
return [] as string[]
}
return summarizeMetadataChanges(comparisonMarkdown, selected.markdown ?? '')
}, [comparisonMarkdown, selected])
const runRestore = useCallback(
async (mode: RestoreMode) => {
if (!selected) {
return
}
try {
setRestoring(`${selected.item.id}:${mode}`)
await adminApi.restorePostRevision(selected.item.id, mode)
toast.success(`已按 ${mode} 模式回滚到版本 #${selected.item.id}`)
await loadRevisions(false)
await openDetail(selected.item.id)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '恢复版本失败。')
} finally {
setRestoring(null)
}
},
[loadRevisions, openDetail, selected],
)
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 rounded-3xl" />
<Skeleton className="h-[580px] 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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Diff </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Markdown 线 full / markdown / metadata
</p>
</div>
</div>
<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"
className="w-[280px]"
/>
<Button variant="secondary" onClick={() => void loadRevisions(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.12fr_0.88fr]">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
{summary.count} {summary.slugs}
</CardDescription>
</div>
<Badge variant="outline">{summary.count}</Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisions.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{item.post_title ?? item.post_slug}</div>
<div className="font-mono text-xs text-muted-foreground">{item.post_slug}</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="secondary">{item.operation}</Badge>
<div className="text-xs text-muted-foreground">
{item.revision_reason ?? '自动记录'}
</div>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.actor_username ?? item.actor_email ?? 'system'}
</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}`}
>
<History className="h-4 w-4" />
/
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>线</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selected ? (
<>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{selected.item.operation}</Badge>
<Badge variant="outline">#{selected.item.id}</Badge>
</div>
<p className="mt-3 font-medium">{selected.item.post_title ?? selected.item.post_slug}</p>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{selected.item.post_slug} · {selected.item.created_at}
</p>
</div>
<div className="space-y-2">
<LabelRow title="比较基线" />
<Select value={compareTarget} onChange={(event) => setCompareTarget(event.target.value)}>
<option value="current">线</option>
{compareCandidates.map((item) => (
<option key={item.id} value={String(item.id)}>
#{item.id} · {item.created_at}
</option>
))}
</Select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
<div className="flex items-center gap-2 text-foreground">
<ArrowLeftRight className="h-4 w-4" />
<span>Diff </span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="success">+{diffStats.additions}</Badge>
<Badge variant="secondary">-{diffStats.deletions}</Badge>
<Badge variant="outline">metadata {metadataChanges.length}</Badge>
</div>
<div className="mt-3 text-xs leading-6 text-muted-foreground">
线{comparisonLabel}
{metadataChanges.length ? ` · 变化字段:${metadataChanges.join('、')}` : ' · Frontmatter 无变化'}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
<div className="font-medium text-foreground"></div>
<div className="mt-3 flex flex-wrap gap-2">
{(['full', 'markdown', 'metadata'] as RestoreMode[]).map((mode) => (
<Button
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" />
{restoring === `${selected.item.id}:${mode}` ? '恢复中...' : mode}
</Button>
))}
</div>
<div className="mt-3 text-xs leading-6 text-muted-foreground">
fullmarkdownmetadata frontmatter / SEO /
</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="space-y-2">
<LabelRow title={comparisonLabel} />
<Textarea
value={comparisonMarkdown}
readOnly
className="min-h-[280px] font-mono text-xs leading-6"
/>
</div>
<div className="space-y-2">
<LabelRow title={`版本 #${selected.item.id}`} />
<Textarea
value={selected.markdown ?? ''}
readOnly
className="min-h-[280px] font-mono text-xs leading-6"
/>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-10 text-center text-sm text-muted-foreground">
Diff
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function LabelRow({ title }: { title: string }) {
return <div className="text-sm font-medium text-foreground">{title}</div>
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,843 @@
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'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
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'
const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'discord', label: 'Discord Webhook' },
{ value: 'telegram', label: 'Telegram Bot API' },
{ value: 'ntfy', label: 'ntfy' },
{ value: 'web_push', label: 'Web Push / Browser Push' },
] as const
const DEFAULT_FILTERS = {
event_types: ['post.published', 'digest.weekly', 'digest.monthly'],
}
function prettyJson(value: unknown) {
if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0)) {
return ''
}
return JSON.stringify(value, null, 2)
}
function emptyForm() {
return {
channelType: 'email',
target: '',
displayName: '',
status: 'active',
notes: '',
filtersText: prettyJson(DEFAULT_FILTERS),
metadataText: '',
}
}
function parseOptionalJson(label: string, raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
try {
return JSON.parse(trimmed) as Record<string, unknown>
} catch {
throw new Error(`${label} 不是合法 JSON`)
}
}
function normalizePreview(value: unknown) {
const text = prettyJson(value)
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[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [submitting, setSubmitting] = useState(false)
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([
adminApi.listSubscriptions(),
adminApi.listSubscriptionDeliveries(),
adminApi.listWorkerJobs({
workerName: 'worker.notification_delivery',
limit: 200,
}),
])
startTransition(() => {
setSubscriptions(nextSubscriptions)
setDeliveries(nextDeliveries)
setWorkerJobs(nextWorkerJobs.jobs)
})
if (showToast) {
toast.success('订阅中心已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载订阅中心。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadData(false)
}, [loadData])
const activeCount = useMemo(
() => subscriptions.filter((item) => item.status === 'active').length,
[subscriptions],
)
const queuedOrRetryCount = useMemo(
() => deliveries.filter((item) => item.status === 'queued' || item.status === 'retry_pending').length,
[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())
}, [])
const submitForm = useCallback(async () => {
try {
setSubmitting(true)
const payload = {
channelType: form.channelType,
target: form.target,
displayName: form.displayName || null,
status: form.status,
notes: form.notes || null,
filters: parseOptionalJson('filters', form.filtersText),
metadata: parseOptionalJson('metadata', form.metadataText),
}
if (editingId) {
await adminApi.updateSubscription(editingId, payload)
toast.success('订阅目标已更新。')
} else {
await adminApi.createSubscription(payload)
toast.success('订阅目标已创建。')
}
resetForm()
await loadData(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存订阅失败。')
} finally {
setSubmitting(false)
}
}, [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">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[640px] 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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> / / </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Webhook / Discord / Telegram / ntfy / Web Push retry pending
</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"
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}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
} finally {
setDigesting(null)
}
}}
>
<Send className="h-4 w-4" />
{digesting === 'weekly' ? '入队中...' : '发送周报'}
</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}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
} finally {
setDigesting(null)
}
}}
>
<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>
<div className="grid gap-6 xl:grid-cols-[0.98fr_1.02fr]">
<Card>
<CardHeader>
<CardTitle>{editingId ? `编辑订阅 #${editingId}` : '新增订阅目标'}</CardTitle>
<CardDescription>
{subscriptions.length} {activeCount} / {queuedOrRetryCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select
value={form.channelType}
onChange={(event) => setForm((current) => ({ ...current, channelType: event.target.value }))}
>
{CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.target}
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
placeholder={
form.channelType === 'email'
? 'name@example.com'
: form.channelType === 'ntfy'
? 'topic-name 或 https://ntfy.example.com/topic'
: form.channelType === 'web_push'
? 'https://push-service/...'
: 'https://...'
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.displayName}
onChange={(event) =>
setForm((current) => ({ ...current, displayName: event.target.value }))
}
placeholder="例如 站长邮箱 / Discord 运维群"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select
value={form.status}
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
>
<option value="active">active</option>
<option value="paused">paused</option>
<option value="pending">pending</option>
<option value="unsubscribed">unsubscribed</option>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.notes}
onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))}
placeholder="用途、机器人说明、负责人等"
/>
</div>
</div>
<div className="space-y-2">
<Label>filtersJSON</Label>
<Textarea
value={form.filtersText}
onChange={(event) => setForm((current) => ({ ...current, filtersText: event.target.value }))}
placeholder='{"event_types":["post.published","digest.weekly"]}'
className="min-h-32 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>metadataJSON</Label>
<Textarea
value={form.metadataText}
onChange={(event) => setForm((current) => ({ ...current, metadataText: event.target.value }))}
placeholder='{"owner":"ops","source":"manual"}'
className="min-h-28 font-mono text-xs"
/>
</div>
<div className="flex flex-wrap gap-3">
<Button
className="flex-1"
disabled={submitting}
onClick={() => void submitForm()}
data-testid="subscriptions-save"
>
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
</Button>
{editingId ? (
<Button variant="outline" disabled={submitting} onClick={resetForm}>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> / / </CardDescription>
</div>
<Badge variant="outline">
{filteredSubscriptions.length} / {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>
</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>
))
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> attempts / next retry / response</CardDescription>
</div>
<Badge variant="outline">{deliveries.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Worker</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map((item) => {
const workerJob = deliveryJobMap.get(item.id)
return (
<TableRow key={item.id}>
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
<TableCell>
<div className="font-medium">{item.event_type}</div>
<div className="text-xs text-muted-foreground">#{item.subscription_id ?? '—'}</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{item.channel_type}</div>
<div className="line-clamp-1 text-xs text-muted-foreground">{item.target}</div>
</div>
</TableCell>
<TableCell>
<Badge variant={item.status === 'sent' ? 'success' : item.status === 'retry_pending' ? 'warning' : 'secondary'}>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<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>
</Card>
</div>
)
}

View File

@@ -0,0 +1,420 @@
import { Plus, RefreshCcw, Save, Tags, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
import type { TagRecord, TaxonomyPayload } from '@/lib/types'
type TagFormState = {
name: string
slug: string
description: string
coverImage: string
accentColor: string
seoTitle: string
seoDescription: string
}
const defaultTagForm: TagFormState = {
name: '',
slug: '',
description: '',
coverImage: '',
accentColor: '',
seoTitle: '',
seoDescription: '',
}
function toFormState(item: TagRecord): TagFormState {
return {
name: item.name,
slug: item.slug,
description: item.description ?? '',
coverImage: item.cover_image ?? '',
accentColor: item.accent_color ?? '',
seoTitle: item.seo_title ?? '',
seoDescription: item.seo_description ?? '',
}
}
function toPayload(form: TagFormState): TaxonomyPayload {
return {
name: form.name.trim(),
slug: emptyToNull(form.slug),
description: emptyToNull(form.description),
coverImage: emptyToNull(form.coverImage),
accentColor: emptyToNull(form.accentColor),
seoTitle: emptyToNull(form.seoTitle),
seoDescription: emptyToNull(form.seoDescription),
}
}
export function TagsPage() {
const [items, setItems] = useState<TagRecord[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [form, setForm] = useState<TagFormState>(defaultTagForm)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const loadTags = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listTags()
startTransition(() => {
setItems(next)
})
if (showToast) {
toast.success('标签列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载标签列表。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadTags(false)
}, [loadTags])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[item.name, item.slug, item.description ?? '', item.seo_title ?? '']
.join('\n')
.toLowerCase()
.includes(keyword),
)
}, [items, searchTerm])
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
[items, selectedId],
)
const resetForm = useCallback(() => {
setSelectedId(null)
setForm(defaultTagForm)
}, [])
const handleSave = useCallback(async () => {
if (!form.name.trim()) {
toast.error('请先填写标签名称。')
return
}
try {
setSaving(true)
if (selectedId) {
const updated = await adminApi.updateTag(selectedId, toPayload(form))
startTransition(() => {
setItems((current) => current.map((item) => (item.id === updated.id ? updated : item)))
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('标签已更新。')
} else {
const created = await adminApi.createTag(toPayload(form))
startTransition(() => {
setItems((current) => [created, ...current])
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('标签已创建。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '保存标签失败。')
} finally {
setSaving(false)
}
}, [form, selectedId])
const handleDelete = useCallback(async () => {
if (!selectedItem) {
return
}
if (!window.confirm(`确认删除标签「${selectedItem.name}」吗?相关文章会同步移除该标签引用。`)) {
return
}
try {
setDeleting(true)
await adminApi.deleteTag(selectedItem.id)
startTransition(() => {
setItems((current) => current.filter((item) => item.id !== selectedItem.id))
})
toast.success('标签已删除。')
resetForm()
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除标签失败。')
} finally {
setDeleting(false)
}
}, [resetForm, selectedItem])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[720px] 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"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
SEO 便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={resetForm}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => void loadTags(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>slug SEO </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
data-testid="tags-search"
placeholder="按标签名 / slug / 描述搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
{filteredItems.length ? (
<div className="space-y-3">
{filteredItems.map((item) => (
<button
key={item.id}
type="button"
data-testid={`tag-item-${item.slug}`}
onClick={() => {
setSelectedId(item.id)
setForm(toFormState(item))
}}
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
selectedId === item.id
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.name}</span>
<Badge variant="outline">#{item.slug}</Badge>
{item.accent_color ? (
<span
className="inline-flex h-5 w-5 rounded-full border border-border/80"
style={{ backgroundColor: item.accent_color }}
/>
) : null}
</div>
<p className="text-sm text-muted-foreground">
{item.description || `${item.count} 篇文章引用了这个标签`}
</p>
</div>
<Badge variant={item.count > 0 ? 'success' : 'secondary'}>{item.count}</Badge>
</div>
</button>
))}
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<Tags className="h-5 w-5" />
</div>
<div>
<CardTitle>{selectedItem ? '编辑标签' : '新建标签'}</CardTitle>
<CardDescription>
SEO
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<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="输入标签名称"
/>
</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>
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
<div className="flex items-center gap-3">
<Input
value={form.accentColor}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
placeholder="#14b8a6"
/>
<input
type="color"
value={form.accentColor || '#14b8a6'}
onChange={(event) =>
setForm((current) => ({ ...current, accentColor: event.target.value }))
}
className="h-10 w-14 rounded-xl border border-input bg-background px-1"
/>
</div>
</FormField>
</div>
<FormField label="标签描述" hint="用于前台标签卡片与专题说明。">
<Textarea
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
rows={4}
placeholder="介绍这个标签常见主题、适合谁看。"
/>
</FormField>
<div className="grid gap-4 lg:grid-cols-2">
<FormField label="SEO 标题" hint="留空时前台继续使用标签名拼接默认标题。">
<Input
value={form.seoTitle}
onChange={(event) =>
setForm((current) => ({ ...current, seoTitle: event.target.value }))
}
placeholder="Astro 相关文章 - Termi"
/>
</FormField>
<FormField label="SEO 描述" hint="搜索引擎摘要与分享描述。">
<Textarea
value={form.seoDescription}
onChange={(event) =>
setForm((current) => ({ ...current, seoDescription: event.target.value }))
}
rows={4}
placeholder="围绕 Astro、内容站与渲染策略的文章汇总。"
/>
</FormField>
</div>
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/50 p-4 md:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-2xl font-semibold text-foreground">{selectedItem?.count ?? 0}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.created_at)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.updated_at)}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving} data-testid="tag-save">
<Save className="h-4 w-4" />
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
</Button>
<Button variant="outline" onClick={resetForm}>
</Button>
<Button
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" />
{deleting ? '删除中...' : '删除标签'}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE?: string
readonly VITE_ADMIN_BASENAME?: string
readonly VITE_FRONTEND_BASE_URL?: string
}
interface ImportMeta {

View File

@@ -15,9 +15,8 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
},
/* Linting */

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
target
target-*
.git
.github
.gitea
node_modules
*.log
*.out
*.err
storage

4
backend/.gitignore vendored
View File

@@ -1,11 +1,11 @@
**/config/local.yaml
**/config/*.local.yaml
**/config/production.yaml
# Generated by Cargo
# will have compiled files and executables
debug/
target/
target-*/
# include cargo lock
!Cargo.lock
@@ -17,4 +17,4 @@ target/
*.pdb
*.sqlite
*.sqlite-*
*.sqlite-*

1690
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@
[package]
name = "termi-api"
version = "0.1.0"
edition = "2021"
edition = "2024"
rust-version = "1.85"
publish = false
default-run = "termi_api-cli"
@@ -36,16 +37,17 @@ chrono = { version = "0.4" }
validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" }
# view engine i18n
fluent-templates = { version = "0.13", features = ["tera"] }
unic-langid = { version = "0.9" }
# /view engine
axum-extra = { version = "0.10", features = ["form"] }
tower-http = { version = "0.6", features = ["cors"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
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"
@@ -56,6 +58,10 @@ name = "tool"
path = "src/bin/tool.rs"
required-features = []
[profile.release]
strip = "symbols"
lto = "thin"
[dev-dependencies]
loco-rs = { workspace = true, features = ["testing"] }
serial_test = { version = "3.1.1" }

45
backend/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1.7
FROM rust:1.94.1-trixie AS chef
RUN rustup component add rustfmt clippy \
&& cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
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
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
FROM debian:trixie-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libgomp1 libstdc++6 tzdata wget \
&& 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/config ./config
COPY --from=builder /app/assets ./assets
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENV RUST_LOG=info
EXPOSE 5150
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=5 CMD wget -q -O /dev/null http://127.0.0.1:5150/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["termi_api-cli", "-e", "production", "start", "--no-banner"]

View File

@@ -1,58 +1,39 @@
# Welcome to Loco :train:
# backend
[Loco](https://loco.rs) is a web and API framework running on Rust.
Loco.rs backend当前仅保留 API 与后台鉴权相关逻辑,不再提供旧的 Tera HTML 后台页面。
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
## 本地启动
## Quick Start
```sh
cargo loco start
```powershell
cargo loco start --server-and-worker
```
```sh
$ cargo loco start
Finished dev [unoptimized + debuginfo] target(s) in 21.63s
Running `target/debug/myapp start`
默认本地监听:
:
:
:
- `http://localhost:5150`
controller/app_routes.rs:203: [Middleware] Adding log trace id
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
▄ ▀
▀ ▄
▄ ▀ ▄ ▄ ▄▀
▄ ▀▄▄
▄ ▀ ▀ ▀▄▀█▄
▀█▄
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
██████ █████ ███ █████ ███ █████ ███ ▀█
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
██████ █████ ███ █████ █████ ███ ████▄
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
██████ █████ ███ ████ ███ █████ ███ ████▀
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
https://loco.rs
## 当前职责
environment: development
database: automigrate
logger: debug
compilation: debug
modes: server
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API
- admin 登录态与后台接口
- 站点设置与 AI 相关后端能力
- Markdown frontmatter 与数据库双向同步
- 内容生命周期:`draft / published / scheduled / offline / expired`
- 可见性与 SEO`public / unlisted / private``canonical``noindex``OG`、redirect
- Webhook 通知:新评论 / 新友链申请
- 内容消费统计:`page_view / read_progress / read_complete`
listening on http://localhost:5150
```
## 生产部署
## Full Stack Serving
生产环境推荐通过环境变量注入:
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
- `APP_BASE_URL`
- `DATABASE_URL`
- `REDIS_URL`
- `JWT_SECRET`
Docker / compose 相关示例见仓库根目录:
## Getting help
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
- `deploy/docker/compose.package.yml`

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
site_name: "InitCool"
site_short_name: "Termi"
site_url: "https://init.cool"
site_title: "InitCool · 中文长文与 AI 搜索实验站"
site_description: "一个偏终端审美的中文内容站用来测试文章检索、AI 问答、段落评论与后台工作流。"
hero_title: "欢迎来到我的中文内容实验站"
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
site_title: "InitCool · 技术笔记与内容档案"
site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
owner_title: "负责把脑洞拧成页面的人"
owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool"
social_twitter: ""
@@ -43,8 +43,15 @@
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"
subscription_verification_mode: "off"
turnstile_site_key: "0x4AAAAAACy58kMBSwXwqMhx"
turnstile_secret_key: "0x4AAAAAACy58m3gYfSqM-VIz4QK4wuO73U"
ai_provider: "newapi"
ai_api_base: "https://91code.jiangnight.com/v1"
ai_api_key: "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571"
@@ -53,3 +60,4 @@
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
ai_top_k: 4
ai_chunk_size: 1200
seo_favicon_url: null

View File

@@ -1,3 +1,40 @@
<html><body>
not found :-(
</body></html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>404 Not Found</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0f172a;
color: #e2e8f0;
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
padding: 24px;
text-align: center;
}
h1 {
margin: 0 0 8px;
font-size: 28px;
}
p {
margin: 0;
color: #94a3b8;
}
</style>
</head>
<body>
<main>
<h1>404</h1>
<p>Not Found</p>
</main>
</body>
</html>

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=/admin">
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting to <a href="/admin">Admin Dashboard</a>...</p>
</body>
</html>

View File

@@ -1,705 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title | default(value="Termi Admin") }} · Termi Admin</title>
<style>
:root {
--bg: #f4f4f5;
--bg-panel: rgba(255, 255, 255, 0.88);
--bg-panel-strong: rgba(255, 255, 255, 0.98);
--line: rgba(24, 24, 27, 0.09);
--line-strong: rgba(24, 24, 27, 0.16);
--text: #09090b;
--text-soft: #52525b;
--text-mute: #71717a;
--accent: #18181b;
--accent-2: #2563eb;
--accent-3: #dc2626;
--accent-4: #16a34a;
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 12px;
--font-sans: "Inter", "Segoe UI", "PingFang SC", sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-sans);
color: var(--text);
background:
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 30%),
linear-gradient(180deg, #fafafa 0%, #f4f4f5 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
.shell {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
min-height: 100vh;
gap: 24px;
padding: 24px;
}
.sidebar,
.surface,
.stat,
.table-panel,
.hero-card,
.form-panel,
.login-panel {
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--bg-panel);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.sidebar {
padding: 28px 22px;
position: sticky;
top: 24px;
height: calc(100vh - 48px);
display: flex;
flex-direction: column;
gap: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 16px;
background: #111827;
border: 1px solid #111827;
font-family: var(--font-mono);
font-weight: 700;
color: #fafafa;
}
.brand-title {
margin: 14px 0 6px;
font-size: 1.35rem;
}
.brand-copy {
margin: 0;
color: var(--text-soft);
line-height: 1.6;
font-size: 0.95rem;
}
.nav-group {
display: grid;
gap: 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
color: var(--text-soft);
border: 1px solid transparent;
transition: 160ms ease;
}
.nav-item:hover,
.nav-item.active {
background: rgba(255, 255, 255, 0.98);
border-color: var(--line);
color: var(--text);
transform: translateX(2px);
}
.nav-item.active {
box-shadow: inset 0 0 0 1px rgba(24, 24, 27, 0.06);
}
.nav-kicker {
margin-top: auto;
padding: 18px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid var(--line);
}
.nav-kicker strong {
display: block;
margin-bottom: 6px;
font-size: 0.98rem;
}
.nav-kicker p {
margin: 0;
color: var(--text-soft);
line-height: 1.55;
font-size: 0.92rem;
}
.content-shell {
display: flex;
flex-direction: column;
gap: 18px;
}
.surface {
padding: 26px 28px;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(24, 24, 27, 0.05);
color: var(--text-soft);
font-size: 0.84rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.page-title {
margin: 12px 0 8px;
font-size: clamp(1.7rem, 2.2vw, 2.5rem);
line-height: 1.1;
}
.page-description {
margin: 0;
max-width: 760px;
color: var(--text-soft);
line-height: 1.7;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 16px;
border-radius: 14px;
border: 1px solid transparent;
cursor: pointer;
transition: 160ms ease;
font-weight: 600;
text-decoration: none;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent);
color: #fafafa;
box-shadow: 0 10px 24px rgba(24, 24, 27, 0.16);
}
.btn-ghost {
background: rgba(255, 255, 255, 0.98);
border-color: var(--line);
color: var(--text);
}
.btn-danger {
background: rgba(220, 38, 38, 0.08);
border-color: rgba(220, 38, 38, 0.14);
color: var(--accent-3);
}
.btn-success {
background: rgba(22, 163, 74, 0.08);
border-color: rgba(22, 163, 74, 0.14);
color: var(--accent-4);
}
.btn-warning {
background: rgba(245, 158, 11, 0.08);
border-color: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.content-grid {
display: grid;
gap: 18px;
}
.stats-grid,
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.stat,
.hero-card,
.table-panel,
.form-panel {
padding: 22px;
border-radius: var(--radius-lg);
background: var(--bg-panel-strong);
}
.stat-label,
.muted,
.table-note,
.field-hint,
.badge-soft {
color: var(--text-mute);
}
.stat-value {
margin: 10px 0 6px;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.tone-blue .stat-value { color: var(--accent-2); }
.tone-gold .stat-value { color: var(--accent); }
.tone-green .stat-value { color: var(--accent-4); }
.tone-pink .stat-value { color: var(--accent-3); }
.tone-violet .stat-value { color: #7a5ef4; }
.table-panel {
overflow: hidden;
}
.table-head {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-end;
margin-bottom: 14px;
}
.table-head h2,
.hero-card h2 {
margin: 0 0 6px;
font-size: 1.15rem;
}
.table-wrap {
overflow: auto;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.98);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 880px;
}
th,
td {
padding: 16px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid rgba(93, 76, 56, 0.1);
}
th {
position: sticky;
top: 0;
background: rgba(250, 250, 250, 0.98);
color: var(--text-soft);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
tr:last-child td {
border-bottom: 0;
}
.item-title {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.item-title strong {
font-size: 0.98rem;
}
.item-meta,
.mono {
color: var(--text-soft);
font-family: var(--font-mono);
font-size: 0.84rem;
word-break: break-all;
}
.badge,
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 0.78rem;
border: 1px solid transparent;
white-space: nowrap;
}
.badge-success {
color: var(--accent-4);
background: rgba(93, 122, 45, 0.1);
border-color: rgba(93, 122, 45, 0.14);
}
.badge-warning {
color: var(--accent);
background: rgba(202, 94, 45, 0.1);
border-color: rgba(202, 94, 45, 0.14);
}
.badge-danger {
color: var(--accent-3);
background: rgba(156, 61, 84, 0.1);
border-color: rgba(156, 61, 84, 0.14);
}
.chip {
background: rgba(241, 245, 249, 0.95);
color: var(--text-soft);
border-color: rgba(148, 163, 184, 0.18);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-link {
color: var(--accent-2);
font-size: 0.88rem;
font-weight: 600;
}
.inline-link:hover {
text-decoration: underline;
}
.empty {
padding: 40px 18px;
text-align: center;
color: var(--text-soft);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.field-wide {
grid-column: 1 / -1;
}
.field label {
display: block;
margin-bottom: 8px;
font-size: 0.9rem;
color: var(--text-soft);
font-weight: 600;
}
.field input,
.field textarea,
.field select {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
color: var(--text);
padding: 14px 16px;
}
.field textarea {
resize: vertical;
min-height: 132px;
}
.inline-form {
display: grid;
gap: 10px;
}
.inline-form.compact {
gap: 8px;
}
.compact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
align-items: end;
}
.compact-grid textarea,
.compact-grid input,
.compact-grid select {
width: 100%;
min-height: 40px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
color: var(--text);
padding: 10px 12px;
}
.compact-grid textarea {
min-height: 84px;
resize: vertical;
}
.compact-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.notice {
display: none;
margin-top: 14px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid transparent;
}
.notice.show {
display: block;
}
.notice-success {
color: var(--accent-4);
background: rgba(22, 163, 74, 0.08);
border-color: rgba(22, 163, 74, 0.14);
}
.notice-error {
color: var(--accent-3);
background: rgba(220, 38, 38, 0.08);
border-color: rgba(220, 38, 38, 0.14);
}
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-panel {
width: min(520px, 100%);
padding: 34px;
}
.login-panel h1 {
margin: 18px 0 10px;
font-size: 2rem;
}
.login-panel p {
margin: 0;
color: var(--text-soft);
line-height: 1.7;
}
.login-error {
display: none;
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(220, 38, 38, 0.08);
border: 1px solid rgba(220, 38, 38, 0.14);
color: var(--accent-3);
}
.login-error.show {
display: block;
}
@media (max-width: 1100px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
}
@media (max-width: 760px) {
.shell,
.surface {
padding: 16px;
}
.topbar {
flex-direction: column;
}
.toolbar {
width: 100%;
justify-content: flex-start;
}
table {
min-width: 760px;
}
}
</style>
</head>
<body>
{% block body %}
<div class="shell">
<aside class="sidebar">
<div>
<div class="brand-mark">/></div>
<h1 class="brand-title">Termi Admin</h1>
<p class="brand-copy">后台数据直接联动前台页面。你可以在这里审核评论和友链、检查分类标签,并跳到对应前台页面确认效果。</p>
</div>
<nav class="nav-group">
<a href="/admin" class="nav-item {% if active_nav == 'dashboard' %}active{% endif %}">概览面板</a>
<a href="/admin/posts" class="nav-item {% if active_nav == 'posts' %}active{% endif %}">文章管理</a>
<a href="/admin/comments" class="nav-item {% if active_nav == 'comments' %}active{% endif %}">评论审核</a>
<a href="/admin/categories" class="nav-item {% if active_nav == 'categories' %}active{% endif %}">分类管理</a>
<a href="/admin/tags" class="nav-item {% if active_nav == 'tags' %}active{% endif %}">标签管理</a>
<a href="/admin/reviews" class="nav-item {% if active_nav == 'reviews' %}active{% endif %}">评价管理</a>
<a href="/admin/friend_links" class="nav-item {% if active_nav == 'friend_links' %}active{% endif %}">友链申请</a>
<a href="/admin/site-settings" class="nav-item {% if active_nav == 'site_settings' %}active{% endif %}">站点设置</a>
</nav>
<div class="nav-kicker">
<strong>前台联调入口</strong>
<p>所有管理页都带了前台直达链接,处理完数据后可以立刻跳转验证。</p>
</div>
</aside>
<div class="content-shell">
<header class="surface topbar">
<div>
<span class="eyebrow">Unified Admin</span>
<h1 class="page-title">{{ page_title | default(value="Termi Admin") }}</h1>
<p class="page-description">{{ page_description | default(value="统一处理后台数据与前台联调。") }}</p>
</div>
<div class="toolbar">
{% for item in header_actions | default(value=[]) %}
<a
href="{{ item.href }}"
class="btn btn-{{ item.variant }}"
{% if item.external %}target="_blank" rel="noreferrer noopener"{% endif %}
>
{{ item.label }}
</a>
{% endfor %}
<a href="/admin/logout" class="btn btn-danger">退出后台</a>
</div>
</header>
<main class="content-grid">
{% block main_content %}{% endblock %}
</main>
</div>
</div>
{% endblock %}
<script>
async function adminPatch(url, payload, successMessage) {
const response = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await response.text() || "request failed");
}
if (successMessage) {
alert(successMessage);
}
location.reload();
}
async function adminDelete(url, successMessage) {
const confirmed = confirm("确认删除这条记录吗?此操作无法撤销。");
if (!confirmed) {
return;
}
const response = await fetch(url, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(await response.text() || "request failed");
}
if (successMessage) {
alert(successMessage);
}
location.reload();
}
</script>
{% block page_scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,85 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新增分类</h2>
<div class="table-note">这里维护分类字典。文章 Markdown 导入时会优先复用这里的分类,不存在才自动创建。</div>
</div>
</div>
<form method="post" action="/admin/categories" class="inline-form">
<div class="compact-grid">
<input type="text" name="name" placeholder="分类名,例如 Technology" value="{{ create_form.name }}" required>
<input type="text" name="slug" placeholder="slug可留空自动生成" value="{{ create_form.slug }}">
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">创建分类</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>分类列表</h2>
<div class="table-note">分类名称会作为文章展示名称使用,文章数来自当前已同步的真实内容。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>分类</th>
<th>文章数</th>
<th>最近文章</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<form method="post" action="/admin/categories/{{ row.id }}/update" class="inline-form compact">
<div class="compact-grid">
<input type="text" name="name" value="{{ row.name }}" required>
<input type="text" name="slug" value="{{ row.slug }}" required>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-success">保存</button>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</form>
</td>
<td><span class="chip">{{ row.count }} 篇</span></td>
<td>
{% if row.latest_frontend_url %}
<a href="{{ row.latest_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.latest_title }}</a>
{% else %}
<span class="badge-soft">{{ row.latest_title }}</span>
{% endif %}
</td>
<td>
<div class="actions">
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台分类页</a>
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
<form method="post" action="/admin/categories/{{ row.id }}/delete">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无分类数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,147 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>评论筛选</h2>
<div class="table-note">按 scope、审核状态、文章 slug 或关键词快速定位评论,尤其适合处理段落评论和垃圾留言。</div>
</div>
</div>
<form method="get" action="/admin/comments" class="inline-form compact">
<div class="compact-grid">
<div class="field">
<label for="scope">评论类型</label>
<select id="scope" name="scope">
<option value="" {% if filters.scope == "" %}selected{% endif %}>全部</option>
<option value="article" {% if filters.scope == "article" %}selected{% endif %}>全文评论</option>
<option value="paragraph" {% if filters.scope == "paragraph" %}selected{% endif %}>段落评论</option>
</select>
</div>
<div class="field">
<label for="approved">审核状态</label>
<select id="approved" name="approved">
<option value="" {% if filters.approved == "" %}selected{% endif %}>全部</option>
<option value="true" {% if filters.approved == "true" %}selected{% endif %}>已审核</option>
<option value="false" {% if filters.approved == "false" %}selected{% endif %}>待审核</option>
</select>
</div>
<div class="field">
<label for="post_slug">文章</label>
<select id="post_slug" name="post_slug">
<option value="" {% if filters.post_slug == "" %}selected{% endif %}>全部文章</option>
{% for slug in post_options %}
<option value="{{ slug }}" {% if filters.post_slug == slug %}selected{% endif %}>{{ slug }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="q">关键词</label>
<input
id="q"
type="text"
name="q"
value="{{ filters.q }}"
placeholder="作者 / 内容 / 段落 key"
/>
</div>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">应用筛选</button>
<a href="/admin/comments" class="btn btn-ghost">清空</a>
</div>
</form>
<div class="inline-links" style="margin-top: 14px;">
{% for stat in stats %}
<span class="chip">{{ stat.label }} · {{ stat.value }}</span>
{% endfor %}
</div>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>评论队列</h2>
<div class="table-note">处理前台真实评论,并能一键跳到对应文章或段落核对上下文。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>作者 / 文章</th>
<th>内容与上下文</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<div class="item-title">
<strong>{{ row.author }}</strong>
<span class="item-meta">{{ row.post_slug }}</span>
{% if row.scope == "paragraph" %}
<span class="badge badge-warning">{{ row.scope_label }}</span>
{% else %}
<span class="badge">{{ row.scope_label }}</span>
{% endif %}
{% if row.frontend_url %}
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">
{% if row.scope == "paragraph" %}跳到前台段落{% else %}跳到前台文章{% endif %}
</a>
{% endif %}
</div>
</td>
<td>
<div class="item-title">
<strong>{{ row.content }}</strong>
{% if row.reply_target != "-" %}
<span class="item-meta">回复目标:{{ row.reply_target }}</span>
{% endif %}
{% if row.scope == "paragraph" and row.paragraph_excerpt != "-" %}
<span class="item-meta">段落上下文:{{ row.paragraph_excerpt }}</span>
{% endif %}
{% if row.scope == "paragraph" and row.paragraph_key != "-" %}
<span class="item-meta">段落 key{{ row.paragraph_key }}</span>
{% endif %}
</div>
</td>
<td>
{% if row.approved %}
<span class="badge badge-success">已审核</span>
{% else %}
<span class="badge badge-warning">待审核</span>
{% endif %}
</td>
<td class="mono">{{ row.created_at }}</td>
<td>
<div class="actions">
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
<button class="btn btn-danger" onclick='adminDelete("{{ row.api_url }}", "评论已删除")'>删除</button>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">当前筛选条件下暂无评论数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,64 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="table-panel">
<div class="table-head">
<div>
<h2>友链审核</h2>
<div class="table-note">前台提交后会进入这里,你可以审核状态,再跳去前台友链页确认展示。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>站点</th>
<th>分类</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<div class="item-title">
<strong>{{ row.site_name }}</strong>
<a href="{{ row.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.site_url }}</a>
<span class="item-meta">{{ row.description }}</span>
</div>
</td>
<td>{{ row.category_name }}</td>
<td>
{% if row.status == "已通过" %}
<span class="badge badge-success">{{ row.status }}</span>
{% elif row.status == "已拒绝" %}
<span class="badge badge-danger">{{ row.status }}</span>
{% else %}
<span class="badge badge-warning">{{ row.status }}</span>
{% endif %}
</td>
<td class="mono">{{ row.created_at }}</td>
<td>
<div class="actions">
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"status": "approved"}, "友链状态已更新")'>通过</button>
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"status": "pending"}, "友链状态已更新")'>待审</button>
<button class="btn btn-danger" onclick='adminPatch("{{ row.api_url }}", {"status": "rejected"}, "友链状态已更新")'>拒绝</button>
<a href="{{ row.frontend_page_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台友链页</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无友链申请数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="stats-grid">
{% for stat in stats %}
<article class="stat tone-{{ stat.tone }}">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="muted">{{ stat.note }}</div>
</article>
{% endfor %}
</section>
<section class="hero-card">
<h2>{{ site_profile.site_name }}</h2>
<p class="page-description" style="margin-bottom: 10px;">{{ site_profile.site_description }}</p>
<a href="{{ site_profile.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ site_profile.site_url }}</a>
</section>
<section class="card-grid">
{% for card in nav_cards %}
<a href="{{ card.href }}" class="hero-card">
<h2>{{ card.title }}</h2>
<p class="page-description" style="margin-bottom: 10px;">{{ card.description }}</p>
<span class="chip">{{ card.meta }}</span>
</a>
{% endfor %}
</section>
{% endblock %}

View File

@@ -1,35 +0,0 @@
{% extends "admin/base.html" %}
{% block body %}
<div class="login-shell">
<section class="login-panel">
<span class="eyebrow">Termi Admin</span>
<div class="brand-mark" style="margin-top: 18px;">/></div>
<h1>后台管理入口</h1>
<p>评论审核、友链申请、分类标签检查和站点设置都在这里统一处理。当前后台界面已经走 Tera 模板,不再在 Rust 里硬拼整页 HTML。</p>
<div class="login-error {% if show_error %}show{% endif %}">
用户名或密码错误,请重试。
</div>
<form method="POST" action="/admin/login" class="form-grid" style="margin-top: 22px;">
<div class="field field-wide">
<label>用户名</label>
<input name="username" placeholder="admin" required>
</div>
<div class="field field-wide">
<label>密码</label>
<input type="password" name="password" placeholder="admin123" required>
</div>
<div class="field field-wide">
<button type="submit" class="btn btn-primary" style="width: 100%;">进入后台</button>
</div>
</form>
<div class="hero-card" style="margin-top: 18px;">
<h2>默认测试账号</h2>
<p class="mono">admin / admin123</p>
</div>
</section>
</div>
{% endblock %}

View File

@@ -1,70 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>{{ editor.title }}</h2>
<div class="table-note">当前源文件:<span class="mono">{{ editor.file_path }}</span></div>
</div>
</div>
<form id="markdown-editor-form" class="form-grid">
<div class="field field-wide">
<label>Slug</label>
<input value="{{ editor.slug }}" readonly>
</div>
<div class="field field-wide">
<label>Markdown 文件内容</label>
<textarea id="markdown-content" name="markdown" style="min-height: 65vh; font-family: var(--font-mono); line-height: 1.65;">{{ editor.markdown }}</textarea>
</div>
<div class="field field-wide">
<div class="actions">
<button type="submit" class="btn btn-primary">保存 Markdown</button>
</div>
<div class="field-hint" style="margin-top: 10px;">这里保存的是服务器上的原始 Markdown 文件。你也可以直接在服务器用编辑器打开这个路径修改。</div>
<div id="notice" class="notice"></div>
</div>
</form>
</section>
{% endblock %}
{% block page_scripts %}
<script>
const markdownForm = document.getElementById("markdown-editor-form");
const markdownField = document.getElementById("markdown-content");
const markdownNotice = document.getElementById("notice");
const markdownSlug = "{{ editor.slug }}";
function showMarkdownNotice(message, kind) {
markdownNotice.textContent = message;
markdownNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
}
markdownForm?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const response = await fetch(`/api/posts/slug/${markdownSlug}/markdown`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
markdown: markdownField.value
})
});
if (!response.ok) {
throw new Error(await response.text() || "save failed");
}
const payload = await response.json();
markdownField.value = payload.markdown;
showMarkdownNotice("Markdown 文件已保存并同步到数据库。", "success");
} catch (error) {
showMarkdownNotice("保存失败:" + (error?.message || "unknown error"), "error");
}
});
</script>
{% endblock %}

View File

@@ -1,199 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新建 Markdown 文章</h2>
<div class="table-note">直接生成 `content/posts/*.md` 文件,后端会自动解析 frontmatter、同步分类和标签。</div>
</div>
</div>
<form method="post" action="/admin/posts" class="form-grid">
<div class="field">
<label>标题</label>
<input type="text" name="title" value="{{ create_form.title }}" required>
</div>
<div class="field">
<label>Slug</label>
<input type="text" name="slug" value="{{ create_form.slug }}" placeholder="可留空自动生成">
</div>
<div class="field">
<label>分类</label>
<input type="text" name="category" value="{{ create_form.category }}" placeholder="例如 tech">
</div>
<div class="field">
<label>标签</label>
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="逗号分隔">
</div>
<div class="field">
<label>文章类型</label>
<input type="text" name="post_type" value="{{ create_form.post_type }}">
</div>
<div class="field">
<label>封面图</label>
<input type="text" name="image" value="{{ create_form.image }}" placeholder="可选">
</div>
<div class="field field-wide">
<label>摘要</label>
<textarea name="description">{{ create_form.description }}</textarea>
</div>
<div class="field field-wide">
<label>正文 Markdown</label>
<textarea name="content" style="min-height: 22rem; font-family: var(--font-mono); line-height: 1.65;">{{ create_form.content }}</textarea>
</div>
<div class="field field-wide">
<div class="actions">
<label class="chip"><input type="checkbox" name="published" checked style="margin-right: 8px;">发布</label>
<label class="chip"><input type="checkbox" name="pinned" style="margin-right: 8px;">置顶</label>
<button type="submit" class="btn btn-primary">创建文章</button>
</div>
</div>
</form>
</section>
<section class="form-panel">
<div class="table-head">
<div>
<h2>导入 Markdown 文件</h2>
<div class="table-note">支持选择单个 `.md/.markdown` 文件,也支持直接选择一个本地 Markdown 文件夹批量导入。</div>
</div>
</div>
<form id="markdown-import-form" class="form-grid">
<div class="field">
<label>选择文件</label>
<input id="markdown-files" type="file" accept=".md,.markdown" multiple>
</div>
<div class="field">
<label>选择文件夹</label>
<input id="markdown-folder" type="file" accept=".md,.markdown" webkitdirectory directory multiple>
</div>
<div class="field field-wide">
<div class="actions">
<button id="import-submit" type="submit" class="btn btn-success">导入 Markdown</button>
</div>
<div class="field-hint" style="margin-top: 10px;">导入时会从 frontmatter 和正文里提取标题、slug、摘要、分类、标签与内容并写入服务器 `content/posts`。</div>
<div id="import-notice" class="notice"></div>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>内容列表</h2>
<div class="table-note">直接跳到前台文章、分类筛选和 API 明细。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>文章</th>
<th>分类</th>
<th>标签</th>
<th>时间</th>
<th>跳转</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<div class="item-title">
<strong>{{ row.title }}</strong>
<span class="item-meta">{{ row.slug }}</span>
<span class="item-meta">{{ row.file_path }}</span>
</div>
</td>
<td>
<div class="item-title">
<strong>{{ row.category_name }}</strong>
<a href="{{ row.category_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">查看该分类文章</a>
</div>
</td>
<td>
<div class="inline-links">
{% if row.tags | length > 0 %}
{% for tag in row.tags %}
<span class="chip">#{{ tag }}</span>
{% endfor %}
{% else %}
<span class="badge-soft">暂无标签</span>
{% endif %}
</div>
</td>
<td class="mono">{{ row.created_at }}</td>
<td>
<div class="actions">
<a href="{{ row.edit_url }}" class="btn btn-success">编辑 Markdown</a>
<a href="{{ row.frontend_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台详情</a>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">当前没有可管理的文章数据。</div>
{% endif %}
</section>
{% endblock %}
{% block page_scripts %}
<script>
const importForm = document.getElementById("markdown-import-form");
const importFiles = document.getElementById("markdown-files");
const importFolder = document.getElementById("markdown-folder");
const importNotice = document.getElementById("import-notice");
function showImportNotice(message, kind) {
importNotice.textContent = message;
importNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
}
importForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const selectedFiles = [
...(importFiles?.files ? Array.from(importFiles.files) : []),
...(importFolder?.files ? Array.from(importFolder.files) : []),
].filter((file) => file.name.endsWith(".md") || file.name.endsWith(".markdown"));
if (!selectedFiles.length) {
showImportNotice("请先选择要导入的 Markdown 文件或文件夹。", "error");
return;
}
const payload = new FormData();
selectedFiles.forEach((file) => {
const uploadName = file.webkitRelativePath || file.name;
payload.append("files", file, uploadName);
});
try {
const response = await fetch("/admin/posts/import", {
method: "POST",
body: payload,
});
if (!response.ok) {
throw new Error(await response.text() || "import failed");
}
const result = await response.json();
showImportNotice(`已导入 ${result.count} 个 Markdown 文件,正在刷新列表。`, "success");
setTimeout(() => window.location.reload(), 900);
} catch (error) {
showImportNotice("导入失败:" + (error?.message || "unknown error"), "error");
}
});
</script>
{% endblock %}

View File

@@ -1,113 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新增评价</h2>
<div class="table-note">这里创建的评价会立刻出现在前台 `/reviews` 页面。</div>
</div>
</div>
<form method="post" action="/admin/reviews" class="inline-form">
<div class="compact-grid">
<input type="text" name="title" placeholder="标题" value="{{ create_form.title }}" required>
<select name="review_type">
<option value="game" {% if create_form.review_type == "game" %}selected{% endif %}>游戏</option>
<option value="anime" {% if create_form.review_type == "anime" %}selected{% endif %}>动画</option>
<option value="music" {% if create_form.review_type == "music" %}selected{% endif %}>音乐</option>
<option value="book" {% if create_form.review_type == "book" %}selected{% endif %}>书籍</option>
<option value="movie" {% if create_form.review_type == "movie" %}selected{% endif %}>影视</option>
</select>
<input type="number" name="rating" min="0" max="5" value="{{ create_form.rating }}" required>
<input type="date" name="review_date" value="{{ create_form.review_date }}">
<select name="status">
<option value="completed" {% if create_form.status == "completed" %}selected{% endif %}>已完成</option>
<option value="in-progress" {% if create_form.status == "in-progress" %}selected{% endif %}>进行中</option>
<option value="dropped" {% if create_form.status == "dropped" %}selected{% endif %}>已弃坑</option>
</select>
<input type="text" name="cover" value="{{ create_form.cover }}" placeholder="封面图标或 emoji">
<input type="url" name="link_url" value="{{ create_form.link_url }}" placeholder="跳转链接,可选">
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="标签,逗号分隔">
<textarea name="description" placeholder="评价描述">{{ create_form.description }}</textarea>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">创建评价</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>评价列表</h2>
<div class="table-note">这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>评价内容</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<form method="post" action="/admin/reviews/{{ row.id }}/update" class="inline-form compact">
<div class="compact-grid">
<input type="text" name="title" value="{{ row.title }}" required>
<select name="review_type">
<option value="game" {% if row.review_type == "game" %}selected{% endif %}>游戏</option>
<option value="anime" {% if row.review_type == "anime" %}selected{% endif %}>动画</option>
<option value="music" {% if row.review_type == "music" %}selected{% endif %}>音乐</option>
<option value="book" {% if row.review_type == "book" %}selected{% endif %}>书籍</option>
<option value="movie" {% if row.review_type == "movie" %}selected{% endif %}>影视</option>
</select>
<input type="number" name="rating" min="0" max="5" value="{{ row.rating }}" required>
<input type="date" name="review_date" value="{{ row.review_date }}">
<select name="status">
<option value="completed" {% if row.status == "completed" %}selected{% endif %}>已完成</option>
<option value="in-progress" {% if row.status == "in-progress" %}selected{% endif %}>进行中</option>
<option value="dropped" {% if row.status == "dropped" %}selected{% endif %}>已弃坑</option>
</select>
<input type="text" name="cover" value="{{ row.cover }}" placeholder="封面图标或 emoji">
<input type="url" name="link_url" value="{{ row.link_url }}" placeholder="跳转链接,可选">
<input type="text" name="tags" value="{{ row.tags_input }}" placeholder="标签,逗号分隔">
<textarea name="description" placeholder="评价描述">{{ row.description }}</textarea>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-success">保存</button>
{% if row.link_url %}
<a href="{{ row.link_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">跳转</a>
{% endif %}
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</form>
</td>
<td><span class="chip">{{ row.status }}</span></td>
<td>
<div class="actions">
<a href="http://localhost:4321/reviews" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台查看</a>
<form method="post" action="/admin/reviews/{{ row.id }}/delete">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无评价数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,224 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>站点资料</h2>
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。AI 问答也在这里统一开启和配置。</div>
</div>
</div>
<form id="site-settings-form" class="form-grid">
<div class="field">
<label>站点名称</label>
<input name="site_name" value="{{ form.site_name }}">
</div>
<div class="field">
<label>短名称</label>
<input name="site_short_name" value="{{ form.site_short_name }}">
</div>
<div class="field">
<label>站点链接</label>
<input name="site_url" value="{{ form.site_url }}">
</div>
<div class="field field-wide">
<label>站点标题</label>
<input name="site_title" value="{{ form.site_title }}">
</div>
<div class="field field-wide">
<label>站点简介</label>
<textarea name="site_description">{{ form.site_description }}</textarea>
</div>
<div class="field">
<label>首页主标题</label>
<input name="hero_title" value="{{ form.hero_title }}">
</div>
<div class="field">
<label>首页副标题</label>
<input name="hero_subtitle" value="{{ form.hero_subtitle }}">
</div>
<div class="field">
<label>个人名称</label>
<input name="owner_name" value="{{ form.owner_name }}">
</div>
<div class="field">
<label>个人头衔</label>
<input name="owner_title" value="{{ form.owner_title }}">
</div>
<div class="field">
<label>头像 URL</label>
<input name="owner_avatar_url" value="{{ form.owner_avatar_url }}">
</div>
<div class="field">
<label>所在地</label>
<input name="location" value="{{ form.location }}">
</div>
<div class="field">
<label>GitHub</label>
<input name="social_github" value="{{ form.social_github }}">
</div>
<div class="field">
<label>Twitter / X</label>
<input name="social_twitter" value="{{ form.social_twitter }}">
</div>
<div class="field field-wide">
<label>Email / mailto</label>
<input name="social_email" value="{{ form.social_email }}">
</div>
<div class="field field-wide">
<label>个人简介</label>
<textarea name="owner_bio">{{ form.owner_bio }}</textarea>
</div>
<div class="field field-wide">
<label>技术栈(每行一个)</label>
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
</div>
<div class="field field-wide" style="border-top: 1px solid rgba(148, 163, 184, 0.18); padding-top: 18px; margin-top: 10px;">
<label style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" name="ai_enabled" {% if form.ai_enabled %}checked{% endif %}>
<span>启用前台 AI 问答</span>
</label>
<div class="field-hint">关闭后,前台导航不会显示 AI 页面公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
</div>
<div class="field">
<label>聊天 Provider</label>
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
</div>
<div class="field">
<label>聊天 API Base</label>
<input name="ai_api_base" value="{{ form.ai_api_base }}" placeholder="https://91code.jiangnight.com/v1">
</div>
<div class="field field-wide">
<label>聊天 API Key</label>
<input name="ai_api_key" value="{{ form.ai_api_key }}" placeholder="sk-...">
<div class="field-hint">这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入 91code.jiangnight.com 的 NewAPI 兼容接口,未配置时前台仍可做本地检索,但不会生成完整聊天回答。</div>
</div>
<div class="field">
<label>聊天模型</label>
<input name="ai_chat_model" value="{{ form.ai_chat_model }}" placeholder="gpt-5.4">
</div>
<div class="field">
<label>本地 Embedding</label>
<input value="{{ form.ai_local_embedding }}" disabled>
</div>
<div class="field">
<label>Top K</label>
<input type="number" min="1" max="12" name="ai_top_k" value="{{ form.ai_top_k }}">
</div>
<div class="field">
<label>Chunk Size</label>
<input type="number" min="400" max="4000" step="50" name="ai_chunk_size" value="{{ form.ai_chunk_size }}">
</div>
<div class="field field-wide">
<label>系统提示词</label>
<textarea name="ai_system_prompt">{{ form.ai_system_prompt }}</textarea>
</div>
<div class="field field-wide">
<div class="table-note">AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。</div>
<div class="actions">
<button type="submit" class="btn btn-primary">保存设置</button>
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
</div>
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。</div>
<div id="notice" class="notice"></div>
</div>
</form>
</section>
{% endblock %}
{% block page_scripts %}
<script>
const form = document.getElementById("site-settings-form");
const notice = document.getElementById("notice");
const reindexBtn = document.getElementById("reindex-btn");
function showNotice(message, kind) {
notice.textContent = message;
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
}
function numericOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
const payload = {
siteName: data.get("site_name"),
siteShortName: data.get("site_short_name"),
siteUrl: data.get("site_url"),
siteTitle: data.get("site_title"),
siteDescription: data.get("site_description"),
heroTitle: data.get("hero_title"),
heroSubtitle: data.get("hero_subtitle"),
ownerName: data.get("owner_name"),
ownerTitle: data.get("owner_title"),
ownerAvatarUrl: data.get("owner_avatar_url"),
location: data.get("location"),
socialGithub: data.get("social_github"),
socialTwitter: data.get("social_twitter"),
socialEmail: data.get("social_email"),
ownerBio: data.get("owner_bio"),
techStack: String(data.get("tech_stack") || "")
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
aiEnabled: data.get("ai_enabled") === "on",
aiProvider: data.get("ai_provider"),
aiApiBase: data.get("ai_api_base"),
aiApiKey: data.get("ai_api_key"),
aiChatModel: data.get("ai_chat_model"),
aiTopK: numericOrNull(data.get("ai_top_k")),
aiChunkSize: numericOrNull(data.get("ai_chunk_size")),
aiSystemPrompt: data.get("ai_system_prompt")
};
try {
const response = await fetch("/api/site_settings", {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(await response.text() || "save failed");
}
showNotice("站点信息与 AI 配置已保存。", "success");
} catch (error) {
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
}
});
reindexBtn?.addEventListener("click", async () => {
reindexBtn.disabled = true;
reindexBtn.textContent = "正在重建...";
try {
const response = await fetch("/api/ai/reindex", {
method: "POST"
});
if (!response.ok) {
throw new Error(await response.text() || "reindex failed");
}
const data = await response.json();
showNotice(`AI 索引已重建,当前共有 ${data.indexed_chunks} 个片段。`, "success");
window.setTimeout(() => window.location.reload(), 900);
} catch (error) {
showNotice("重建失败:" + (error?.message || "unknown error"), "error");
} finally {
reindexBtn.disabled = false;
reindexBtn.textContent = "重建 AI 索引";
}
});
</script>
{% endblock %}

View File

@@ -1,77 +0,0 @@
{% extends "admin/base.html" %}
{% block main_content %}
<section class="form-panel">
<div class="table-head">
<div>
<h2>新增标签</h2>
<div class="table-note">这里维护标签字典。文章 Markdown 导入时会优先复用这里的标签,不存在才自动创建。</div>
</div>
</div>
<form method="post" action="/admin/tags" class="inline-form">
<div class="compact-grid">
<input type="text" name="name" placeholder="标签名,例如 Rust" value="{{ create_form.name }}" required>
<input type="text" name="slug" placeholder="slug可留空自动生成" value="{{ create_form.slug }}">
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-primary">创建标签</button>
</div>
</form>
</section>
<section class="table-panel">
<div class="table-head">
<div>
<h2>标签映射</h2>
<div class="table-note">标签名称会作为文章展示名称使用,使用次数来自当前已同步的真实文章内容。</div>
</div>
</div>
{% if rows | length > 0 %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>标签</th>
<th>使用次数</th>
<th>跳转</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">#{{ row.id }}</td>
<td>
<form method="post" action="/admin/tags/{{ row.id }}/update" class="inline-form compact">
<div class="compact-grid">
<input type="text" name="name" value="{{ row.name }}" required>
<input type="text" name="slug" value="{{ row.slug }}" required>
</div>
<div class="compact-actions">
<button type="submit" class="btn btn-success">保存</button>
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
</div>
</form>
</td>
<td><span class="chip">{{ row.usage_count }} 篇文章</span></td>
<td>
<div class="actions">
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台标签页</a>
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
<form method="post" action="/admin/tags/{{ row.id }}/delete">
<button type="submit" class="btn btn-danger">删除</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无标签数据。</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,12 +0,0 @@
<html><body>
<img src="/static/image.png" width="200"/>
<br/>
find this tera template at <code>assets/views/home/hello.html</code>:
<br/>
<br/>
{{ t(key="hello-world", lang="en-US") }},
<br/>
{{ t(key="hello-world", lang="de-DE") }}
</body></html>

View File

@@ -1,330 +0,0 @@
Compiling proc-macro2 v1.0.106
Compiling quote v1.0.45
Compiling unicode-ident v1.0.24
Compiling serde_core v1.0.228
Compiling serde v1.0.228
Compiling getrandom v0.3.4
Compiling autocfg v1.5.0
Compiling find-msvc-tools v0.1.9
Compiling shlex v1.3.0
Compiling version_check v0.9.5
Compiling crossbeam-utils v0.8.21
Compiling zmij v1.0.21
Compiling zerocopy v0.8.47
Compiling serde_json v1.0.149
Compiling pkg-config v0.3.32
Compiling icu_normalizer_data v2.1.1
Compiling icu_properties_data v2.1.2
Compiling thiserror v2.0.18
Compiling libc v0.2.183
Compiling typenum v1.19.0
Compiling generic-array v0.14.7
Compiling rustls v0.23.37
Compiling num-traits v0.2.19
Compiling libm v0.2.16
Compiling getrandom v0.4.2
Compiling windows_x86_64_msvc v0.52.6
Compiling jobserver v0.1.34
Compiling ident_case v1.0.1
Compiling parking_lot_core v0.9.12
Compiling regex-syntax v0.8.10
Compiling crc32fast v1.5.0
Compiling httparse v1.10.1
Compiling bigdecimal v0.4.10
Compiling cc v1.2.57
Compiling crossbeam-epoch v0.9.18
Compiling rust_decimal v1.40.0
Compiling windows-targets v0.52.6
Compiling rand v0.10.0
Compiling proc-macro-hack v0.5.20+deprecated
Compiling crossbeam-deque v0.8.6
Compiling rand_core v0.6.4
Compiling windows_x86_64_msvc v0.48.5
Compiling flate2 v1.1.9
Compiling windows_x86_64_msvc v0.53.1
Compiling syn v2.0.117
Compiling rand v0.8.5
Compiling rayon-core v1.13.0
Compiling regex-automata v0.4.14
Compiling num-integer v0.1.46
Compiling zstd-safe v7.2.4
Compiling windows-sys v0.59.0
Compiling concurrent-queue v2.5.0
Compiling log v0.4.29
Compiling num-bigint v0.4.6
Compiling phf_generator v0.11.3
Compiling block-buffer v0.10.4
Compiling crypto-common v0.1.7
Compiling winapi v0.3.9
Compiling vcpkg v0.2.15
Compiling anyhow v1.0.102
Compiling native-tls v0.2.18
Compiling digest v0.10.7
Compiling object v0.37.3
Compiling phf_codegen v0.11.3
Compiling sha2 v0.10.9
Compiling event-listener v5.4.1
Compiling hashbrown v0.16.1
Compiling deranged v0.5.8
Compiling uuid v1.23.0
Compiling ring v0.17.14
Compiling zstd-sys v2.0.16+zstd.1.5.7
Compiling windows-targets v0.53.5
Compiling libsqlite3-sys v0.30.1
Compiling windows-targets v0.48.5
Compiling crossbeam-queue v0.3.12
Compiling ahash v0.8.12
Compiling windows-sys v0.48.0
Compiling indexmap v2.13.0
Compiling windows-sys v0.60.2
Compiling time v0.3.47
Compiling hmac v0.12.1
Compiling regex v1.12.3
Compiling md-5 v0.10.6
Compiling atoi v2.0.0
Compiling proc-macro-error-attr2 v2.0.0
Compiling rustversion v1.0.22
Compiling parse-zoneinfo v0.3.1
Compiling etcetera v0.8.0
Compiling hkdf v0.12.4
Compiling rand_core v0.9.5
Compiling chrono-tz-build v0.3.0
Compiling proc-macro2-diagnostics v0.10.1
Compiling portable-atomic v1.13.1
Compiling base64ct v1.8.3
Compiling socks v0.3.4
Compiling paste v1.0.15
Compiling pem-rfc7468 v1.0.0
Compiling ignore v0.4.25
Compiling ordered-float v4.6.0
Compiling yansi v1.0.1
Compiling thiserror v1.0.69
Compiling ureq-proto v0.6.0
Compiling der v0.8.0
Compiling globwalk v0.9.1
Compiling stacker v0.1.23
Compiling num-rational v0.4.2
Compiling humansize v2.1.3
Compiling fs-err v2.11.0
Compiling synstructure v0.13.2
Compiling darling_core v0.20.11
Compiling proc-macro-error2 v2.0.1
Compiling pest_generator v2.8.6
Compiling multer v3.1.0
Compiling chrono-tz v0.9.0
Compiling av-scenechange v0.14.1
Compiling utf8-zero v0.8.1
Compiling unicode-xid v0.2.6
Compiling built v0.8.0
Compiling ureq v3.3.0
Compiling shared_child v1.1.1
Compiling onig_sys v69.9.1
Compiling matrixmultiply v0.3.10
Compiling cookie v0.18.1
Compiling hmac-sha256 v1.1.14
Compiling rav1e v0.8.1
Compiling pastey v0.1.1
Compiling lzma-rust2 v0.15.7
Compiling duct v1.1.1
Compiling serde_path_to_error v0.1.20
Compiling ar_archive_writer v0.5.1
Compiling simd_helpers v0.1.0
Compiling include_dir_macros v0.7.4
Compiling windows-sys v0.52.0
Compiling crossbeam-channel v0.5.15
Compiling esaxx-rs v0.1.10
Compiling tokio-cron-scheduler v0.11.1
Compiling noop_proc_macro v0.3.0
Compiling console v0.15.11
Compiling include_dir v0.7.4
Compiling castaway v0.2.4
Compiling globset v0.4.18
Compiling serde_derive v1.0.228
Compiling displaydoc v0.2.5
Compiling zerofrom-derive v0.1.6
Compiling yoke-derive v0.8.1
Compiling zerovec-derive v0.11.2
Compiling tokio-macros v2.6.1
Compiling tracing-attributes v0.1.31
Compiling zerocopy-derive v0.8.47
Compiling thiserror-impl v2.0.18
Compiling futures-macro v0.3.32
Compiling rustls-webpki v0.103.10
Compiling darling_macro v0.20.11
Compiling tinystr v0.8.2
Compiling tokio v1.50.0
Compiling unic-langid-impl v0.9.6
Compiling equator-macro v0.4.2
Compiling psm v0.1.30
Compiling zerofrom v0.1.6
Compiling darling v0.20.11
Compiling futures-util v0.3.32
Compiling yoke v0.8.1
Compiling inherent v1.0.13
Compiling num-derive v0.4.2
Compiling tracing v0.1.44
Compiling unic-langid-macros-impl v0.9.6
Compiling zerovec v0.11.5
Compiling zerotrie v0.2.3
Compiling equator v0.4.2
Compiling clap_derive v4.6.0
Compiling pest_derive v2.8.6
Compiling sea-query-derive v0.4.3
Compiling aligned-vec v0.6.4
Compiling thiserror-impl v1.0.69
Compiling v_frame v0.3.9
Compiling sea-bae v0.2.1
Compiling async-trait v0.1.89
Compiling profiling-procmacros v1.0.17
Compiling derive_more-impl v2.1.1
Compiling potential_utf v0.1.4
Compiling icu_locale_core v2.1.1
Compiling icu_collections v2.1.1
Compiling arg_enum_proc_macro v0.3.4
Compiling unic-langid-macros v0.9.6
Compiling futures-executor v0.3.32
Compiling futures v0.3.32
Compiling icu_provider v2.1.1
Compiling unic-langid v0.9.6
Compiling smallvec v1.15.1
Compiling chrono v0.4.44
Compiling either v1.15.0
Compiling serde_urlencoded v0.7.1
Compiling icu_properties v2.1.2
Compiling tracing-serde v0.2.0
Compiling icu_normalizer v2.1.1
Compiling tokio-util v0.7.18
Compiling tokio-stream v0.1.18
Compiling tower v0.5.3
Compiling parking_lot v0.12.5
Compiling rayon v1.11.0
Compiling tokio-rustls v0.26.4
Compiling idna_adapter v1.2.1
Compiling h2 v0.4.13
Compiling ppv-lite86 v0.2.21
Compiling futures-intrusive v0.5.0
Compiling idna v1.1.0
Compiling tokio-native-tls v0.3.1
Compiling sea-query v0.32.7
Compiling rand_chacha v0.3.1
Compiling rand_chacha v0.9.0
Compiling itertools v0.14.0
Compiling url v2.5.8
Compiling hashbrown v0.14.5
Compiling rand v0.9.2
Compiling clap v4.6.0
Compiling sqlx-core v0.8.6
Compiling tracing-subscriber v0.3.23
Compiling async-stream-impl v0.3.6
Compiling ouroboros_macro v0.18.5
Compiling maybe-rayon v0.1.1
Compiling half v2.7.1
Compiling derive_more v2.1.1
Compiling serde_spanned v0.6.9
Compiling serde_regex v1.1.0
Compiling serde_yaml v0.9.34+deprecated
Compiling toml_datetime v0.6.11
Compiling tera v1.20.1
Compiling async-stream v0.3.6
Compiling sea-orm-macros v1.1.19
Compiling profiling v1.0.17
Compiling av1-grain v0.2.5
Compiling hyper v1.8.1
Compiling axum-core v0.5.6
Compiling derive_builder_core v0.20.2
Compiling sqlx-postgres v0.8.6
Compiling sqlx-sqlite v0.8.6
Compiling hyper-util v0.1.20
Compiling ouroboros v0.18.5
Compiling ort-sys v2.0.0-rc.11
Compiling fax_derive v0.2.0
Compiling axum-macros v0.5.0
Compiling sea-schema-derive v0.3.0
Compiling fax v0.2.6
Compiling hyper-tls v0.6.0
Compiling hyper-rustls v0.27.7
Compiling rrgen v0.5.6
Compiling derive_builder_macro v0.20.2
Compiling chumsky v0.9.3
Compiling sea-orm-cli v1.1.19
Compiling toml_edit v0.22.27
Compiling combine v4.6.7
Compiling cron v0.12.1
Compiling backon v1.6.0
Compiling quick-xml v0.38.4
Compiling simple_asn1 v0.6.4
Compiling validator_derive v0.20.0
Compiling socket2 v0.5.10
Compiling monostate-impl v0.1.18
Compiling serde_html_form v0.2.8
Compiling sqlx v0.8.6
Compiling colored v2.2.0
Compiling blake2 v0.10.6
Compiling sea-query-binder v0.7.0
Compiling num-complex v0.4.6
Compiling macro_rules_attribute-proc_macro v0.2.2
Compiling loco-rs v0.16.4
Compiling moxcms v0.8.1
Compiling axum v0.8.8
Compiling sea-schema v0.16.2
Compiling sea-orm v1.1.19
Compiling validator v0.20.0
Compiling ndarray v0.17.2
Compiling macro_rules_attribute v0.2.2
Compiling spm_precompiled v0.1.4
Compiling lettre v0.11.19
Compiling exr v1.74.0
Compiling backtrace_printer v1.3.0
Compiling zstd v0.13.3
Compiling moka v0.12.15
Compiling compression-codecs v0.4.37
Compiling ravif v0.13.0
Compiling async-compression v0.4.41
Compiling redis v0.31.0
Compiling tower-http v0.6.8
Compiling indicatif v0.17.11
Compiling argon2 v0.5.3
Compiling reqwest v0.12.28
Compiling axum-extra v0.10.3
Compiling byte-unit v4.0.19
Compiling loco-gen v0.16.4
Compiling jsonwebtoken v9.3.1
Compiling notify v8.2.0
Compiling png v0.18.1
Compiling monostate v0.1.18
Compiling toml v0.8.23
Compiling onig v6.5.1
Compiling derive_builder v0.20.2
Compiling tiff v0.11.3
Compiling tracing-appender v0.2.4
Compiling opendal v0.54.1
Compiling rayon-cond v0.4.0
Compiling ulid v1.2.1
Compiling dashmap v6.1.0
Compiling ureq v2.12.1
Compiling unicode-normalization-alignments v0.1.12
Compiling intl_pluralrules v7.0.2
Compiling intl-memoizer v0.5.3
Compiling fluent-langneg v0.13.1
Compiling compact_str v0.9.0
Compiling ipnetwork v0.20.0
Compiling dary_heap v0.3.8
Compiling serde_variant v0.1.3
Compiling fluent-syntax v0.12.0
Compiling tower v0.4.13
Compiling duct_sh v1.0.0
Compiling fluent-bundle v0.16.0
Compiling tokenizers v0.22.2
Compiling hf-hub v0.4.3
Compiling image v0.25.10
Compiling ort v2.0.0-rc.11
Compiling safetensors v0.7.0
Compiling sea-orm-migration v1.1.19
Compiling fluent-template-macros v0.13.3
Compiling fluent-templates v0.13.3
Compiling fastembed v5.13.0
Compiling migration v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend\migration)
Compiling termi-api v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 23s
Running `target\debug\termi_api-cli.exe start`
error: process didn't exit successfully: `target\debug\termi_api-cli.exe start` (exit code: 0xffffffff)

View File

@@ -1,26 +0,0 @@
2026-03-29T11:49:41.902355Z  WARN loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml)
▄ ▀
▀ ▄
▄ ▀ ▄ ▄ ▄▀
▄ ▀▄▄
▄ ▀ ▀ ▀▄▀█▄
▀█▄
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
██████ █████ ███ █████ ███ █████ ███ ▀█
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
██████ █████ ███ █████ █████ ███ ████▄
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
██████ █████ ███ ████ ███ █████ ███ ████▀
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
https://loco.rs
environment: development
database: automigrate
logger: debug
compilation: debug
modes: server
listening on http://localhost:5150
2026-03-29T11:50:40.675162Z ERROR http-request: loco_rs::controller: controller_error error.msg=AI provider returned 429 Too Many Requests: {"error":{"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}} error.details=BadRequest("AI provider returned 429 Too Many Requests: {\"error\":{\"message\":\"Concurrency limit exceeded for user, please retry later\",\"type\":\"rate_limit_error\"}}") http.method=POST http.uri=/api/ai/ask http.version=HTTP/1.1 http.user_agent=Mozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.26200; zh-CN) PowerShell/7.5.5 environment=development request_id=160e41d4-83b3-49d9-ad6d-e26498301ab9

View File

@@ -1,529 +0,0 @@
Compiling proc-macro2 v1.0.106
Compiling unicode-ident v1.0.24
Compiling quote v1.0.45
Compiling syn v2.0.117
Compiling cfg-if v1.0.4
Compiling serde_core v1.0.228
Compiling memchr v2.8.0
Compiling windows-link v0.2.1
Compiling serde v1.0.228
Compiling serde_derive v1.0.228
Compiling windows-sys v0.61.2
Compiling getrandom v0.3.4
Compiling itoa v1.0.18
Compiling autocfg v1.5.0
Compiling once_cell v1.21.4
Compiling jobserver v0.1.34
Compiling find-msvc-tools v0.1.9
Compiling shlex v1.3.0
Compiling cc v1.2.57
Compiling log v0.4.29
Compiling pin-project-lite v0.2.17
Compiling bytes v1.11.1
Compiling stable_deref_trait v1.2.1
Compiling version_check v0.9.5
Compiling num-traits v0.2.19
Compiling smallvec v1.15.1
Compiling displaydoc v0.2.5
Compiling synstructure v0.13.2
Compiling zerofrom-derive v0.1.6
Compiling zerofrom v0.1.6
Compiling yoke-derive v0.8.1
Compiling futures-core v0.3.32
Compiling yoke v0.8.1
Compiling percent-encoding v2.3.2
Compiling zerovec-derive v0.11.2
Compiling crossbeam-utils v0.8.21
Compiling zerovec v0.11.5
Compiling allocator-api2 v0.2.21
Compiling socket2 v0.6.3
Compiling mio v1.1.1
Compiling tokio-macros v2.6.1
Compiling tokio v1.50.0
Compiling tinystr v0.8.2
Compiling aho-corasick v1.1.4
Compiling futures-sink v0.3.32
Compiling tracing-core v0.1.36
Compiling equivalent v1.0.2
Compiling zerocopy v0.8.47
Compiling zmij v1.0.21
Compiling getrandom v0.2.17
Compiling tracing-attributes v0.1.31
Compiling zerocopy-derive v0.8.47
Compiling serde_json v1.0.149
Compiling zeroize v1.8.2
Compiling tracing v0.1.44
Compiling foldhash v0.2.0
Compiling base64 v0.22.1
Compiling hashbrown v0.16.1
Compiling slab v0.4.12
Compiling pkg-config v0.3.32
Compiling futures-channel v0.3.32
Compiling fnv v1.0.7
Compiling indexmap v2.13.0
Compiling futures-macro v0.3.32
Compiling thiserror-impl v2.0.18
Compiling futures-io v0.3.32
Compiling subtle v2.6.1
Compiling futures-task v0.3.32
Compiling futures-util v0.3.32
Compiling litemap v0.8.1
Compiling writeable v0.6.2
Compiling icu_locale_core v2.1.1
Compiling potential_utf v0.1.4
Compiling zerotrie v0.2.3
Compiling num-integer v0.1.46
Compiling icu_properties_data v2.1.2
Compiling thiserror v2.0.18
Compiling icu_normalizer_data v2.1.1
Compiling icu_provider v2.1.1
Compiling icu_collections v2.1.1
Compiling form_urlencoded v1.2.2
Compiling ring v0.17.14
Compiling libc v0.2.183
Compiling bitflags v2.11.0
Compiling regex-syntax v0.8.10
Compiling regex-automata v0.4.14
Compiling scopeguard v1.2.0
Compiling typenum v1.19.0
Compiling lock_api v0.4.14
Compiling icu_normalizer v2.1.1
Compiling icu_properties v2.1.2
Compiling num-bigint v0.4.6
Compiling rustls-pki-types v1.14.0
Compiling generic-array v0.14.7
Compiling ryu v1.0.23
Compiling untrusted v0.9.0
Compiling strsim v0.11.1
Compiling idna_adapter v1.2.1
Compiling crossbeam-epoch v0.9.18
Compiling utf8_iter v1.0.4
Compiling idna v1.1.0
Compiling ppv-lite86 v0.2.21
Compiling chrono v0.4.44
Compiling either v1.15.0
Compiling rustls v0.23.37
Compiling url v2.5.8
Compiling crossbeam-deque v0.8.6
Compiling rustls-webpki v0.103.10
Compiling arrayvec v0.7.6
Compiling libm v0.2.16
Compiling crypto-common v0.1.7
Compiling block-buffer v0.10.4
Compiling webpki-roots v1.0.6
Compiling http v1.4.0
Compiling getrandom v0.4.2
Compiling num-conv v0.2.1
Compiling ident_case v1.0.1
Compiling powerfmt v0.2.0
Compiling windows_x86_64_msvc v0.52.6
Compiling time-core v0.1.8
Compiling rand_core v0.10.0
Compiling simd-adler32 v0.3.9
Compiling time-macros v0.2.27
Compiling deranged v0.5.8
Compiling darling_core v0.20.11
Compiling digest v0.10.7
Compiling cpufeatures v0.3.0
Compiling byteorder v1.5.0
Compiling chacha20 v0.10.0
Compiling darling_macro v0.20.11
Compiling time v0.3.47
Compiling regex v1.12.3
Compiling rand_core v0.6.4
Compiling tokio-util v0.7.18
Compiling crc32fast v1.5.0
Compiling parking_lot_core v0.9.12
Compiling adler2 v2.0.1
Compiling siphasher v1.0.2
Compiling miniz_oxide v0.8.9
Compiling windows-targets v0.52.6
Compiling darling v0.20.11
Compiling rand v0.10.0
Compiling http-body v1.0.1
Compiling spin v0.9.8
Compiling heck v0.4.1
Compiling httparse v1.10.1
Compiling tower-service v0.3.3
Compiling uuid v1.23.0
Compiling serde_urlencoded v0.7.1
Compiling zstd-sys v2.0.16+zstd.1.5.7
Compiling httpdate v1.0.3
Compiling flate2 v1.1.9
Compiling phf_shared v0.11.3
Compiling rand_chacha v0.3.1
Compiling webpki-roots v0.26.11
Compiling bigdecimal v0.4.10
Compiling windows_x86_64_msvc v0.48.5
Compiling proc-macro-hack v0.5.20+deprecated
Compiling atomic-waker v1.1.2
Compiling windows_x86_64_msvc v0.53.1
Compiling rust_decimal v1.40.0
Compiling try-lock v0.2.5
Compiling mime v0.3.17
Compiling lazy_static v1.5.0
Compiling want v0.3.1
Compiling h2 v0.4.13
Compiling rand v0.8.5
Compiling parking_lot v0.12.5
Compiling windows-strings v0.5.1
Compiling windows-result v0.4.1
Compiling bstr v1.12.1
Compiling tower-layer v0.3.3
Compiling pin-utils v0.1.0
Compiling zstd-safe v7.2.4
Compiling alloc-no-stdlib v2.0.4
Compiling cpufeatures v0.2.17
Compiling rayon-core v1.13.0
Compiling foldhash v0.1.5
Compiling hashbrown v0.15.5
Compiling alloc-stdlib v0.2.2
Compiling hyper v1.8.1
Compiling windows-registry v0.6.1
Compiling unic-langid-impl v0.9.6
Compiling phf_generator v0.11.3
Compiling http-body-util v0.1.3
Compiling windows-sys v0.59.0
Compiling concurrent-queue v2.5.0
Compiling sync_wrapper v1.0.2
Compiling winapi-util v0.1.11
Compiling parking v2.2.1
Compiling native-tls v0.2.18
Compiling tinyvec_macros v0.1.1
Compiling object v0.37.3
Compiling anyhow v1.0.102
Compiling vcpkg v0.2.15
Compiling winapi v0.3.9
Compiling ipnet v2.12.0
Compiling crc-catalog v2.4.0
Compiling crc v3.4.0
Compiling hyper-util v0.1.20
Compiling libsqlite3-sys v0.30.1
Compiling tinyvec v1.11.0
Compiling event-listener v5.4.1
Compiling same-file v1.0.6
Compiling parse-zoneinfo v0.3.1
Compiling windows-targets v0.53.5
Compiling unic-langid-macros-impl v0.9.6
Compiling windows-targets v0.48.5
Compiling phf_codegen v0.11.3
Compiling brotli-decompressor v5.0.0
Compiling hashlink v0.10.0
Compiling sha2 v0.10.9
Compiling futures-intrusive v0.5.0
Compiling phf v0.11.3
Compiling tokio-stream v0.1.18
Compiling crossbeam-queue v0.3.12
Compiling ahash v0.8.12
Compiling schannel v0.1.29
Compiling unicase v2.9.0
Compiling ucd-trie v0.1.7
Compiling heck v0.5.0
Compiling pest v2.8.6
Compiling mime_guess v2.0.5
Compiling sqlx-core v0.8.6
Compiling chrono-tz-build v0.3.0
Compiling brotli v8.0.2
Compiling windows-sys v0.48.0
Compiling windows-sys v0.60.2
Compiling zstd v0.13.3
Compiling walkdir v2.5.0
Compiling unicode-normalization v0.1.25
Compiling tower v0.5.3
Compiling flume v0.11.1
Compiling hmac v0.12.1
Compiling md-5 v0.10.6
Compiling atoi v2.0.0
Compiling home v0.5.12
Compiling encoding_rs v0.8.35
Compiling equator-macro v0.4.2
Compiling proc-macro-error-attr2 v2.0.0
Compiling rustversion v1.0.22
Compiling compression-core v0.4.31
Compiling utf8parse v0.2.2
Compiling anstyle v1.0.14
Compiling unicode-bidi v0.3.18
Compiling unicode-segmentation v1.13.2
Compiling unicode-properties v0.1.4
Compiling once_cell_polyfill v1.70.2
Compiling dotenvy v0.15.7
Compiling anstyle-wincon v3.0.11
Compiling stringprep v0.1.5
Compiling anstyle-parse v1.0.0
Compiling compression-codecs v0.4.37
Compiling proc-macro-error2 v2.0.1
Compiling equator v0.4.2
Compiling etcetera v0.8.0
Compiling hkdf v0.12.4
Compiling socks v0.3.4
Compiling ar_archive_writer v0.5.1
Compiling chrono-tz v0.9.0
Compiling pest_meta v2.8.6
Compiling rayon v1.11.0
Compiling globset v0.4.18
Compiling tokio-rustls v0.26.4
Compiling futures-executor v0.3.32
Compiling proc-macro2-diagnostics v0.10.1
Compiling rand_core v0.9.5
Compiling anstyle-query v1.1.5
Compiling nom v8.0.0
Compiling colorchoice v1.0.5
Compiling whoami v1.6.1
Compiling is_terminal_polyfill v1.70.2
Compiling hex v0.4.3
Compiling base64ct v1.8.3
Compiling paste v1.0.15
Compiling portable-atomic v1.13.1
Compiling static_assertions v1.1.0
Compiling minimal-lexical v0.2.1
Compiling nom v7.1.3
Compiling pem-rfc7468 v1.0.0
Compiling sqlx-postgres v0.8.6
Compiling anstream v1.0.0
Compiling rand_chacha v0.9.0
Compiling sqlx-sqlite v0.8.6
Compiling ignore v0.4.25
Compiling sea-query-derive v0.4.3
Compiling pest_generator v2.8.6
Compiling psm v0.1.30
Compiling aligned-vec v0.6.4
Compiling async-compression v0.4.41
Compiling tokio-native-tls v0.3.1
Compiling ordered-float v4.6.0
Compiling inherent v1.0.13
Compiling num-derive v0.4.2
Compiling clap_lex v1.1.0
Compiling http-range-header v0.4.2
Compiling deunicode v1.6.2
Compiling yansi v1.0.1
Compiling iri-string v0.7.11
Compiling thiserror v1.0.69
Compiling tower-http v0.6.8
Compiling slug v0.1.6
Compiling clap_builder v4.6.0
Compiling sea-query v0.32.7
Compiling ureq-proto v0.6.0
Compiling unic-langid-macros v0.9.6
Compiling webpki-root-certs v1.0.6
Compiling hyper-tls v0.6.0
Compiling v_frame v0.3.9
Compiling pest_derive v2.8.6
Compiling globwalk v0.9.1
Compiling sqlx v0.8.6
Compiling rand v0.9.2
Compiling der v0.8.0
Compiling hyper-rustls v0.27.7
Compiling clap_derive v4.6.0
Compiling sharded-slab v0.1.7
Compiling humansize v2.1.3
Compiling itertools v0.14.0
Compiling num-rational v0.4.2
Compiling matchers v0.2.0
Compiling tracing-serde v0.2.0
Compiling tracing-log v0.2.0
Compiling multer v3.1.0
Compiling as-slice v0.2.1
Compiling stacker v0.1.23
Compiling fs-err v2.11.0
Compiling nu-ansi-term v0.50.3
Compiling thread_local v1.1.9
Compiling thiserror-impl v1.0.69
Compiling av-scenechange v0.14.1
Compiling utf8-zero v0.8.1
Compiling glob v0.3.3
Compiling built v0.8.0
Compiling unicode-xid v0.2.6
Compiling derive_more-impl v2.1.1
Compiling rav1e v0.8.1
Compiling ureq v3.3.0
Compiling tracing-subscriber v0.3.23
Compiling aligned v0.4.3
Compiling tera v1.20.1
Compiling clap v4.6.0
Compiling reqwest v0.12.28
Compiling sea-query-binder v0.7.0
Compiling unic-langid v0.9.6
Compiling ouroboros_macro v0.18.5
Compiling hashbrown v0.14.5
Compiling sea-bae v0.2.1
Compiling shared_child v1.1.1
Compiling futures v0.3.32
Compiling onig_sys v69.9.1
Compiling cookie v0.18.1
Compiling matrixmultiply v0.3.10
Compiling os_pipe v1.2.3
Compiling core2 v0.4.0
Compiling profiling-procmacros v1.0.17
Compiling arg_enum_proc_macro v0.3.4
Compiling async-trait v0.1.89
Compiling async-stream-impl v0.3.6
Compiling aliasable v0.1.3
Compiling unsafe-libyaml v0.2.11
Compiling lzma-rust2 v0.15.7
Compiling y4m v0.8.0
Compiling hmac-sha256 v1.1.14
Compiling quick-error v2.0.1
Compiling pastey v0.1.1
Compiling fastrand v2.3.0
Compiling shared_thread v0.2.0
Compiling duct v1.1.1
Compiling ort-sys v2.0.0-rc.11
Compiling serde_yaml v0.9.34+deprecated
Compiling ouroboros v0.18.5
Compiling async-stream v0.3.6
Compiling profiling v1.0.17
Compiling bitstream-io v4.9.0
Compiling sea-orm-macros v1.1.19
Compiling derive_more v2.1.1
Compiling av1-grain v0.2.5
Compiling maybe-rayon v0.1.1
Compiling axum-core v0.5.6
Compiling sea-schema-derive v0.3.0
Compiling derive_builder_core v0.20.2
Compiling windows-sys v0.52.0
Compiling serde_regex v1.1.0
Compiling cruet v0.13.3
Compiling half v2.7.1
Compiling crossbeam-channel v0.5.15
Compiling serde_path_to_error v0.1.20
Compiling toml_datetime v0.6.11
Compiling serde_spanned v0.6.9
Compiling fax_derive v0.2.0
Compiling axum-macros v0.5.0
Compiling include_dir_macros v0.7.4
Compiling simd_helpers v0.1.0
Compiling noop_proc_macro v0.3.0
Compiling new_debug_unreachable v1.0.6
Compiling matchit v0.8.4
Compiling toml_write v0.1.2
Compiling esaxx-rs v0.1.10
Compiling rustc-hash v2.1.1
Compiling winnow v0.7.15
Compiling strum v0.26.3
Compiling zune-core v0.5.1
Compiling imgref v1.12.0
Compiling unicode-width v0.2.2
Compiling option-ext v0.2.0
Compiling rawpointer v0.2.1
Compiling tokio-cron-scheduler v0.11.1
Compiling encode_unicode v1.0.0
Compiling weezl v0.1.12
Compiling console v0.15.11
Compiling password-hash v0.5.0
Compiling dirs-sys v0.5.0
Compiling loop9 v0.1.5
Compiling zune-jpeg v0.5.15
Compiling sea-orm v1.1.19
Compiling toml_edit v0.22.27
Compiling type-map v0.5.1
Compiling axum v0.8.8
Compiling include_dir v0.7.4
Compiling fax v0.2.6
Compiling rrgen v0.5.6
Compiling socket2 v0.5.10
Compiling derive_builder_macro v0.20.2
Compiling sea-schema v0.16.2
Compiling chumsky v0.9.3
Compiling backon v1.6.0
Compiling sea-orm-cli v1.1.19
Compiling castaway v0.2.4
Compiling cron v0.12.1
Compiling validator_derive v0.20.0
Compiling colored v2.2.0
Compiling combine v4.6.7
Compiling cruet v0.14.0
Compiling simple_asn1 v0.6.4
Compiling blake2 v0.10.6
Compiling zune-inflate v0.2.54
Compiling fdeflate v0.3.7
Compiling avif-serialize v0.8.8
Compiling serde_html_form v0.2.8
Compiling notify-types v2.1.0
Compiling pem v3.0.6
Compiling email-encoding v0.4.1
Compiling num-complex v0.4.6
Compiling colored v3.1.1
Compiling quick-xml v0.38.4
Compiling hostname v0.4.2
Compiling monostate-impl v0.1.18
Compiling utf8-width v0.1.8
Compiling byteorder-lite v0.1.0
Compiling quoted_printable v0.5.2
Compiling base64 v0.13.1
Compiling color_quant v1.1.0
Compiling pxfm v0.1.28
Compiling tagptr v0.2.0
Compiling loco-rs v0.16.4
Compiling macro_rules_attribute-proc_macro v0.2.2
Compiling sha1_smol v1.0.1
Compiling rgb v0.8.53
Compiling bytemuck v1.25.0
Compiling email_address v0.2.9
Compiling bit_field v0.10.3
Compiling btparse-stable v0.1.2
Compiling lebe v0.5.3
Compiling number_prefix v0.4.0
Compiling indicatif v0.17.11
Compiling exr v1.74.0
Compiling backtrace_printer v1.3.0
Compiling lettre v0.11.19
Compiling qoi v0.4.1
Compiling ravif v0.13.0
Compiling redis v0.31.0
Compiling macro_rules_attribute v0.2.2
Compiling moka v0.12.15
Compiling moxcms v0.8.1
Compiling gif v0.14.1
Compiling spm_precompiled v0.1.4
Compiling image-webp v0.2.4
Compiling byte-unit v4.0.19
Compiling monostate v0.1.18
Compiling opendal v0.54.1
Compiling loco-gen v0.16.4
Compiling ndarray v0.17.2
Compiling jsonwebtoken v9.3.1
Compiling notify v8.2.0
Compiling axum-extra v0.10.3
Compiling png v0.18.1
Compiling argon2 v0.5.3
Compiling validator v0.20.0
Compiling compact_str v0.9.0
Compiling sea-orm-migration v1.1.19
Compiling onig v6.5.1
Compiling derive_builder v0.20.2
Compiling tiff v0.11.3
Compiling intl-memoizer v0.5.3
Compiling toml v0.8.23
Compiling dirs v6.0.0
Compiling tracing-appender v0.2.4
Compiling duct_sh v1.0.0
Compiling dashmap v6.1.0
Compiling intl_pluralrules v7.0.2
Compiling fluent-langneg v0.13.1
Compiling rayon-cond v0.4.0
Compiling ulid v1.2.1
Compiling ureq v2.12.1
Compiling tower v0.4.13
Compiling english-to-cron v0.1.7
Compiling fluent-syntax v0.12.0
Compiling unicode-normalization-alignments v0.1.12
Compiling ipnetwork v0.20.0
Compiling serde_variant v0.1.3
Compiling dary_heap v0.3.8
Compiling unicode_categories v0.1.1
Compiling self_cell v1.2.2
Compiling semver v1.0.27
Compiling fluent-bundle v0.16.0
Compiling tokenizers v0.22.2
Compiling fluent-template-macros v0.13.3
Compiling hf-hub v0.4.3
Compiling image v0.25.10
Compiling ort v2.0.0-rc.11
Compiling safetensors v0.7.0
Compiling fastembed v5.13.0
Compiling fluent-templates v0.13.3
Compiling migration v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend\migration)
Compiling termi-api v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend)
Finished `dev` profile [unoptimized] target(s) in 8m 53s
Running `target\debug\termi_api-cli.exe start`
error: process didn't exit successfully: `target\debug\termi_api-cli.exe start` (exit code: 1073807364)

View File

@@ -1,25 +0,0 @@
2026-03-28T15:13:51.613322Z  WARN loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml)
▄ ▀
▀ ▄
▄ ▀ ▄ ▄ ▄▀
▄ ▀▄▄
▄ ▀ ▀ ▀▄▀█▄
▀█▄
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
██████ █████ ███ █████ ███ █████ ███ ▀█
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
██████ █████ ███ █████ █████ ███ ████▄
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
██████ █████ ███ ████ ███ █████ ███ ████▀
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
https://loco.rs
environment: development
database: automigrate
logger: debug
compilation: debug
modes: server
listening on http://localhost:5150

View File

@@ -31,8 +31,6 @@ server:
folder:
uri: "/static"
path: "assets/static"
# fallback to index.html which redirects to /admin
fallback: "assets/static/index.html"
# Worker Configuration
workers:

View File

@@ -0,0 +1,59 @@
logger:
enable: true
pretty_backtrace: false
level: info
format: json
server:
port: {{ get_env(name="PORT", default="5150") }}
binding: 0.0.0.0
host: {{ get_env(name="APP_BASE_URL", default="http://localhost:5150") }}
middlewares:
static:
enable: true
must_exist: true
precompressed: false
folder:
uri: "/static"
path: "assets/static"
workers:
mode: BackgroundQueue
queue:
kind: Redis
uri: {{ get_env(name="REDIS_URL", default="redis://redis:6379") }}
dangerously_flush: false
mailer:
smtp:
enable: {{ get_env(name="SMTP_ENABLE", default="false") }}
host: '{{ get_env(name="SMTP_HOST", default="localhost") }}'
port: {{ get_env(name="SMTP_PORT", default="1025") }}
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
{% set smtp_user = get_env(name="SMTP_USER", default="") %}
{% if smtp_user != "" %}
auth:
user: '{{ smtp_user }}'
password: '{{ get_env(name="SMTP_PASSWORD", default="") }}'
{% endif %}
{% set smtp_hello_name = get_env(name="SMTP_HELLO_NAME", default="") %}
{% if smtp_hello_name != "" %}
hello_name: '{{ smtp_hello_name }}'
{% endif %}
database:
uri: {{ get_env(name="DATABASE_URL", default="postgres://termi:termi@db:5432/termi_api") }}
enable_logging: false
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="10") }}
auto_migrate: true
dangerously_truncate: false
dangerously_recreate: false
auth:
jwt:
secret: {{ get_env(name="JWT_SECRET", default="please-change-me") }}
expiration: {{ get_env(name="JWT_EXPIRATION_SECONDS", default="604800") }}

View File

@@ -29,7 +29,6 @@ server:
folder:
uri: "/static"
path: "assets/static"
fallback: "assets/static/404.html"
# Worker Configuration
workers:

View File

@@ -0,0 +1,24 @@
---
title: 徐霞客游记·游太和山日记(下)
slug: building-blog-with-astro
description: 《徐霞客游记》太和山下篇,包含琼台、南岩与五龙宫等段落。
category: 古籍游记
post_type: article
pinned: false
status: published
visibility: public
noindex: false
tags:
- 徐霞客
- 游记
- 太和山
- 山水游记
---
# 徐霞客游记·游太和山日记(下)
更衣上金顶。瞻叩毕,天宇澄朗,下瞰诸峰,近者鹄峙,远者罗列,诚天真奥区也。
遂从三天门之右小径下峡中。此径无级无索,乱峰离立,路穿其间,迥觉幽胜。三里馀,抵蜡烛峰右,泉涓涓溢出路旁,下为蜡烛涧。
从宫左趋雷公洞。洞在悬崖间。乃从北天门下,一径阴森,滴水、仙侣二岩,俱在路左,飞崖上突,泉滴沥于中。

View File

@@ -1,242 +0,0 @@
---
title: "Canokey入门指南:2FA、OpenPGP、PIV"
description: 本文是一份Canokey入门指南将介绍如何使用Canokey进行2FA、OpenPGP和PIV等操作。其中2FA部分将介绍如何使用Yubikey Authenticator进行管理OpenPGP部分将介绍如何生成GPG密钥并使用Canokey进行身份验证和加密解密PIV部分将介绍如何在Canokey中生成PIV证书并使用其进行身份验证。
date: 2022-08-19T16:42:40+08:00
draft: false
slug: canokeys
image:
categories:
- Linux
tags:
- Linux
---
# 2FA
`Canokey`使用`Yubikey Authenticator`来进行管理`2FA`
下载`Yubikey Authenticator`,以下为`Yubikey Authenticator`官方下载网址
```http
https://www.yubico.com/products/yubico-authenticator/#h-download-yubico-authenticator
```
运行`Yubikey Authenticator`
进入`custom reader`,在`Custom reader fiter`处填入 `CanoKey`
![填入CanoKey](https://upload-images.jianshu.io/upload_images/9676051-ff0cd60f38ac7334.png)
右上角`Add account` 增加`2FA`
![添加2FA](https://upload-images.jianshu.io/upload_images/9676051-1031857fe0f13d08.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
```yaml
Issuer: 备注 可选
Account name : 用户名 必填项
Secret Key : Hotp或Totp的key 必填项
```
# OpenPGP
## 安装GPG
Windows 用户可下载 [Gpg4Win](https://gpg4win.org/download.html)Linux/macOS 用户使用对应包管理软件安装即可.
## 生成主密钥
```shell
gpg --expert --full-gen-key #生成GPG KEY
```
推荐使用`ECC`算法
![image-20220102223722475](https://upload-images.jianshu.io/upload_images/9676051-df42e4b958e9a238.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
```shell
选择(11) ECC (set your own capabilities) # 设置自己的功能 主密钥只保留 Certify 功能其他功能Encr,Sign,Auth使用子密钥
# 子密钥分成三份,分别获得三个不同的功能
# encr 解密功能
# sign 签名功能
# auth 登录验证功能
```
```shell
先选择 (S) Toggle the sign capability
```
![image-20220102224151589](https://upload-images.jianshu.io/upload_images/9676051-c3bb19eb398419e1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
```
之后输入q 退出
```
键入1,选择默认算法
![键入1,选择默认算法](https://upload-images.jianshu.io/upload_images/9676051-7a2c5ee8ed4800af.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
设置主密钥永不过期
![image-20220102224451731](https://upload-images.jianshu.io/upload_images/9676051-cca6100917c2ffaa.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
填写信息,按照实际情况填写即可
![image-20220102224612167](https://upload-images.jianshu.io/upload_images/9676051-10430afe3aa592c7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
```
Windnows 下会弹出窗口输入密码,注意一定要保管好!!!
```
```shell
```
```shell
# 会自动生成吊销证书,注意保存到安全的地方
gpg: AllowSetForegroundWindow(22428) failed: <20>ܾ<EFBFBD><DCBE><EFBFBD><EFBFBD>ʡ<EFBFBD>
gpg: revocation certificate stored as 'C:\\Users\\Andorid\\AppData\\Roaming\\gnupg\\openpgp-revocs.d\\<此处为私钥>.rev'
# 以上的REV文件即为吊销证书
public and secret key created and signed.
```
```shell
pub ed25519 2022-01-02 [SC]
<此处为Pub>
uid <此处为Name> <此处为email>
```
生成子密钥
```shell
gpg --fingerprint --keyid-format long -K
```
下面生成不同功能的子密钥,其中 `<fingerprint>` 为上面输出的密钥指纹,本示例中即为 `私钥`。最后的 `2y` 为密钥过期时间,可自行设置,如不填写默认永不过期。
```shell
gpg --quick-add-key <fingerprint> cv25519 encr 2y
gpg --quick-add-key <fingerprint> ed25519 auth 2y
gpg --quick-add-key <fingerprint> ed25519 sign 2y
```
再次查看目前的私钥,可以看到已经包含了这三个子密钥。
```shell
gpg --fingerprint --keyid-format long -K
```
上面生成了三种功能的子密钥ssb分别为加密E、认证A、签名S对应 `OpenPGP Applet` 中的三个插槽。由于 `ECC` 实现的原因,加密密钥的算法区别于其他密钥的算法。
加密密钥用于加密文件和信息。签名密钥主要用于给自己的信息签名,保证这真的是来自**我**的信息。认证密钥主要用于 SSH 登录。
## 备份GPG
```shell
# 公钥
gpg -ao public-key.pub --export <ed25519/16位>
# 主密钥,请务必保存好!!!
# 注意 key id 后面的 !,表示只导出这一个私钥,若没有的话默认导出全部私钥。
gpg -ao sec-key.asc --export-secret-key <ed25519/16位>!
# sign子密钥
gpg -ao sign-key.asc --export-secret-key <ed25519/16位>!
gpg -ao auth-key.asc --export-secret-key <ed25519/16位>!
gpg -ao encr-key.asc --export-secret-key <ed25519/16位>!
```
## 导入Canokey
```shell
# 查看智能卡设备状态
gpg --card-status
# 写入GPG
gpg --edit-key <ed25519/16位> # 为上方的sec-key
# 选中第一个子密钥
key 1
# 写入到智能卡
keytocard
# 再次输入,取消选择
key 1
# 选择第二个子密钥
key 2
keytocard
key 2
key 3
keytocard
# 保存修改并退出
save
#再次查看设备状态,可以看到此时子密钥标识符为 ssb>,表示本地只有一个指向 card-no: F1D0 xxxxxxxx 智能卡的指针,已不存在私钥。现在可以删除掉主密钥了,请再次确认你已安全备份好主密钥。
gpg --card-status
```
## 删除本地密钥
```shell
gpg --delete-secret-keys <ed25519/16位> # 为上方的sec-key
```
为确保安全,也可直接删除 gpg 的工作目录:`%APPDATA%\gnupg`Linux/macOS: `~/.gunpg`
## 使用 Canokey
此时切换回日常使用的环境,首先导入公钥
```shell
gpg --import public-key.pub
```
然后设置子密钥指向 Canokey
```shell
gpg --edit-card
gpg/card> fetch
```
此时查看本地的私钥,可以看到已经指向了 Canokey
```
gpg --fingerprint --keyid-format long -K
```
配置gpg路径
```bash
git config --global gpg.program "C:\Program Files (x86)\GnuPG\bin\gpg.exe" --replace-all
```
## Git Commit 签名
首先确保 Git 本地配置以及 GitHub 中的邮箱信息包含在 `UID` 中,然后设置 Git 来指定使用子密钥中的签名S密钥。
```shell
git config --global user.signingkey <ed25519/16位> # 为上方的Sign密钥
```
之后在 `git commit` 时增加 `-S` 参数即可使用 gpg 进行签名。也可在配置中设置自动 gpg 签名,此处不建议全局开启该选项,因为有的脚本可能会使用 `git am` 之类的涉及到 `commit` 的命令,如果全局开启的话会导致问题。
```shell
git config commit.gpgsign true
```
如果提交到 GitHub前往 [GitHub SSH and GPG keys](https://github.com/settings/keys) 添加公钥。此处添加后,可以直接通过对应 GitHub ID 来获取公钥:`https://github.com/<yourid>.gpg`
## PIV
首先在Web端添加自己的私钥到智能卡之后前往 [WinCrypt SSH Agent](https://github.com/buptczq/WinCryptSSHAgent) 下载并运行,此时查看 `ssh-agent` 读取到的公钥信息,把输出的公钥信息添加到服务器的 `~/.ssh/authorized_keys`
```shell
# 设置环境池
$Env:SSH_AUTH_SOCK="\\.\pipe\openssh-ssh-agent"
# 查看ssh列表
ssh-add -L
```
此时连接 `ssh user@host`,会弹出提示输入 `PIN` 的页面,注意此时输入的是 `PIV Applet PIN`,输入后即可成功连接服务器。
```yaml
tips: 可能会出现权限不够的情况,需要禁用Windows服务OpenSSH Authentication Agent
```
最后可以把该程序快捷方式添加到启动目录 `%AppData%\Microsoft\Windows\Start Menu\Programs\Startup`,方便直接使用。

View File

@@ -1,67 +0,0 @@
---
title: "如何使用FFmpeg处理音视频文件"
description: 本文提供了FFmpeg处理音视频文件的完整指南包括将单张图片转换为视频、拼接多个视频、设置转场特效等多种操作。
date: 2022-07-25T14:05:04+08:00
draft: true
slug: ffmpeg
image:
categories: ffmpeg
tags: ffmpeg
---
# `ffmpeg`图片转视频
使用单张图片生成5秒视频
```bash
# -loop 1 指定开启单帧图片loop
# -t 5 指定loop时长为5秒
# -i input 指定输入图片文件路径 示例:pic.jpg
# -pix_fmt 指定编码格式为yuv420p
# -y 若输出文件已存在,则强制进行覆盖。
# ffmpeg会根据输出文件后缀,自动选择编码格式。
# 也可以使用 -f 指定输出格式
ffmpeg -loop 1 -t 5 -i <filename>.jpg -pix_fmt yuv420p -y output.ts
```
# `ffmpeg`拼接视频
```bash
# windows
# -i input 指定需要合并的文件,使用concat进行合并.示例:"concat:0.ts|1.ts|2.ts"
# -vcodec 指定视频编码器的参数为copy
# -acodec 指定音频编码器的参数为copy
# -y 若输出文件已存在,则强制进行覆盖。
ffmpeg -i "concat:0.ts|1.ts" -vcodec copy -acodec copy -y output.ts
```
# `ffmpeg`设置转场特效
```bash
# Linux
ffmpeg -i v0.mp4 -i v1.mp4 -i v2.mp4 -i v3.mp4 -i v4.mp4 -filter_complex \
"[0][1:v]xfade=transition=fade:duration=1:offset=3[vfade1]; \
[vfade1][2:v]xfade=transition=fade:duration=1:offset=10[vfade2]; \
[vfade2][3:v]xfade=transition=fade:duration=1:offset=21[vfade3]; \
[vfade3][4:v]xfade=transition=fade:duration=1:offset=25,format=yuv420p; \
[0:a][1:a]acrossfade=d=1[afade1]; \
[afade1][2:a]acrossfade=d=1[afade2]; \
[afade2][3:a]acrossfade=d=1[afade3]; \
[afade3][4:a]acrossfade=d=1" \
-movflags +faststart out.mp4
```
| 输入文件 | 输入文件的视频总长 | + | previous xfade `offset` | - | xfade `duration` | `offset` = |
| :------- | :----------------- | :--: | :---------------------- | :--: | :--------------- | :--------- |
| `v0.mp4` | 4 | + | 0 | - | 1 | 3 |
| `v1.mp4` | 8 | + | 3 | - | 1 | 10 |
| `v2.mp4` | 12 | + | 10 | - | 1 | 21 |
| `v3.mp4` | 5 | + | 21 | - | 1 | 25 |
// 将音频转为单声道
```
ffmpeg -i .\1.mp3 -ac 1 -ar 44100 -ab 16k -vol 50 -f 1s.mp3
ffmpeg -i one.ts -i 1s.mp3 -map 0:v -map 1:a -c:v copy -shortest -af apad -y one1.ts
```

View File

@@ -1,121 +0,0 @@
---
title: "使用arm交叉编译工具并解决GLIBC版本不匹配的问题"
description: 介绍如何使用arm交叉编译工具来编译Go程序并解决在arm平台上运行时出现GLIBC版本不匹配的问题。
date: 2022-06-10T15:00:26+08:00
draft: false
slug: go-arm
image:
categories:
- Go
tags:
- Arm
- Go
- GLIBC
---
1. 下载 ARM 交叉编译工具,可以从官方网站下载。比如,可以从如下链接下载 GNU 工具链:[https://developer.arm.com/downloads/-/gnu-a](https://developer.arm.com/downloads/-/gnu-a)
示例:https://developer.arm.com/-/media/Files/downloads/gnu-a/10.3-2021.07/binrel/gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-elf.tar.xz
2. 设置 Go ARM 交叉编译环境变量。具体来说,需要设置以下变量:
```ruby
$env:GOOS="linux"
$env:GOARCH="arm64"
$env:CGO_ENABLED=1
$env:CC="D:\arm\gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-linux-gnu\bin\aarch64-none-linux-gnu-gcc.exe"
$env:CXX="D:\arm\gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-linux-gnu\bin\aarch64-none-linux-gnu-g++.exe"
```
3. 在 ARM 上运行程序时可能会出现如下错误:
```bash
./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by ./bupload)
./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.32' not found (required by ./bupload)
./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found (required by ./bupload)
```
这是因为程序需要使用较新版本的 GLIBC 库,而 ARM 上安装的库版本较旧。可以通过以下步骤来解决这个问题:
4. 查看当前系统中 libc 库所支持的版本:
```bash
strings /lib/aarch64-linux-gnu/libc.so.6 | grep GLIBC_
```
5. 备份整个 `/lib` 目录和 `/usr/include` 目录,以便稍后还原。
6. 从 GNU libc 官方网站下载对应版本的 libc 库。例如,可以从如下链接下载 2.35 版本的 libc 库:[http://ftp.gnu.org/gnu/glibc/glibc-2.35.tar.xz](http://ftp.gnu.org/gnu/glibc/glibc-2.35.tar.xz)
7. 解压 libc 库:
```
xz -d glibc-2.35.tar.xz
tar xvf glibc-2.35.tar glibc-2.35
```
8. 创建并进入 build 目录:
```bash
mkdir build
cd build
```
9. 配置 libc 库的安装选项:
```javascript
../configure --prefix=/usr --disable-profile --enable-add-ons --with-headers=/usr/include --with-binutils=/usr/bin
```
10. 编译并安装 libc 库:
```go
make -j4
make install
```
接下来是关于 `make` 报错的部分:
```yaml
asm/errno.h: No such file or directory
```
这个报错是因为 `errno.h` 文件中包含了 `asm/errno.h` 文件,但是找不到这个文件。为了解决这个问题,我们需要创建一个软链接:
```bash
ln -s /usr/include/asm-generic /usr/include/asm
```
然后又出现了另一个报错:
```bash
/usr/include/aarch64-linux-gnu/asm/sigcontext.h: No such file or directory
```
这个问题也可以通过重新安装`linux-libc-dev`后创建软链接来解决:
```bash
# find / -name sigcontext.h
sudo apt-get install --reinstall linux-libc-dev
ln -s /usr/include/aarch64-linux-gnu/asm/sigcontext.h /usr/include/asm/sigcontext.h
```
接下来,还有一个报错:
```yaml
asm/sve_context.h: No such file or directory
```
这个报错是因为最新的 Linux 内核在启用 ARM Scalable Vector Extension (SVE) 后,需要包含 `asm/sve_context.h` 文件。我们需要创建一个软链接来解决这个问题:
```bash
# find / -name sve_context.h
ln -s /usr/include/aarch64-linux-gnu/asm/sve_context.h /usr/include/asm/sve_context.h
```
最后,还需要创建一个软链接:
```bash
# find / -name byteorder.h
ln -s /usr/include/aarch64-linux-gnu/asm/byteorder.h /usr/include/asm/byteorder.h
```
完成以上步骤后,我们再次执行 `make` 命令,就应该可以顺利地编译和安装 glibc 了。

View File

@@ -1,173 +0,0 @@
---
title: "Go使用gRPC进行通信"
description: RPC是远程过程调用的简称是分布式系统中不同节点间流行的通信方式。
date: 2022-05-26T14:17:33+08:00
draft: false
slug: go-grpc
image:
categories:
- Go
tags:
- Go
- gRPC
---
# 安装`gRPC`和`Protoc`
## 安装`protobuf`
```bash
go get -u google.golang.org/protobuf
go get -u google.golang.org/protobuf/proto
go get -u google.golang.org/protobuf/protoc-gen-go
```
## 安装`Protoc`
```shell
# 下载二进制文件并添加至环境变量
https://github.com/protocolbuffers/protobuf/releases
```
安装`Protoc`插件`protoc-gen-go`
```shell
# go install 会自动编译项目并添加至环境变量中
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
```
```shell
#protoc-gen-go 文档地址
https://developers.google.com/protocol-buffers/docs/reference/go-generated
```
# 创建`proto`文件并定义服务
## 新建 `task.proto`文件
```shell
touch task.proto
```
## 编写`task.proto`
```protobuf
// 指定proto版本
syntax = "proto3";
// 指定包名
package task;
// 指定输出 go 语言的源码到哪个目录和 包名
// 主要 目录和包名用 ; 隔开
// 将在当前目录生成 task.pb.go
// 也可以只填写 "./",会生成的包名会变成 "----"
option go_package = "./;task";
// 指定RPC的服务名
service TaskService {
// 调用 AddTaskCompletion 方法
rpc AddTaskCompletion(request) returns (response);
}
// RPC TaskService服务,AddTaskCompletion函数的请求参数,即消息
message request {
uint32 id = 1;//任务id
string module = 2;//所属模块
int32 value = 3;//此次完成值
string guid = 4;//用户id
}
// RPC TaskService服务,TaskService函数的返回值,即消息
message response{
}
```
## 使用`Protoc`来生成Go代码
```bash
protoc --go_out=. --go-grpc_out=. <要进行生成代码的文件>.proto
# example
protoc --go_out=. --go-grpc_out=. .\task.proto
```
这样生成会生成两个`.go`文件,一个是对应消息`task.pb.go`,一个对应服务接口`task_grpc.pb.go`
`task_grpc.pb.go`中,在我们定义的服务接口中,多增加了一个私有的接口方法:
`mustEmbedUnimplementedTaskServiceServer()`
# 使用`Go`监听`gRPC`服务端及客户端
## 监听服务端
并有生成的一个`UnimplementedTaskServiceServer`结构体来实现了所有的服务接口。因此,在我们自己实现的服务类中,需要继承这个结构体,如:
```go
// 用于实现grpc服务 TaskServiceServer 接口
type TaskServiceImpl struct {
// 需要继承结构体 UnimplementedServiceServer 或mustEmbedUnimplementedTaskServiceServer
task.mustEmbedUnimplementedTaskServiceServer()
}
func main() {
// 创建Grpc服务
// 创建tcp连接
listener, err := net.Listen("tcp", ":8082")
if err != nil {
fmt.Println(err)
return
}
// 创建grpc服务
grpcServer := grpc.NewServer()
// 此函数在task.pb.go中,自动生成
task.RegisterTaskServiceServer(grpcServer, &TaskServiceImpl{})
// 在grpc服务上注册反射服务
reflection.Register(grpcServer)
// 启动grpc服务
err = grpcServer.Serve(listener)
if err != nil {
fmt.Println(err)
return
}
}
func (s *TaskServiceImpl) AddTaskCompletion(ctx context.Context, in *task.Request) (*task.Response, error) {
fmt.Println("收到一个Grpc 请求, 请求参数为", in.Guid)
r := &task.Response{
}
return r, nil
}
```
然后在`TaskService`上实现我们的服务接口。
## 客户端
```go
conn, err := grpc.Dial("127.0.0.1:8082", grpc.WithInsecure())
if err != nil {
panic(err)
}
defer conn.Close()
// 创建grpc客户端
client := task.NewTaskServiceClient(conn)
// 创建请求
req := &task.Request{
Id: 1,
Module: "test",
Value: 3,
Guid: "test",
}
// 调用rpc TaskService AddTaskCompletion函数
response, err := client.AddTaskCompletion(context.Background(), req)
if err != nil {
log.Println(err)
return
}
log.Println(response)
```
[本文参考](https://www.cnblogs.com/whuanle/p/14588031.html)

View File

@@ -1,98 +0,0 @@
---
title: "Go语言解析Xml"
slug: "go-xml"
date: 2022-05-20T14:38:05+08:00
draft: false
description: "使用Go简简单单的解析Xml"
tags:
- Go
- Xml
categories:
- Go
---
# 开始之前
```go
import "encoding/xml"
```
## 简单的`Xml`解析
### 1.假设我们解析的`Xml`内容如下:
```xml
<feed>
<person name="initcool" id="1" age=18 />
</feed>
```
<!--more-->
### 2.接着我们构造对应的结构体
```go
type Feed struct {
XMLName xml.Name `xml:"feed"`
Person struct{
Name string `xml:"name"`
Id string `xml:"id"`
Age int `xml:"age"`
} `xml:"person"`
}
```
### 3.对`Xml`数据进行反序列化
```go
var feed Feed
// 读取Xml文件并返回字节流
content,err := ioutil.ReadFile(XmlFilename)
if err != nil {
log.Fatal(err)
}
// 将读取到的内容反序列化到feed
xml.Unmarshal(content,&feed)
```
## 带有命名空间的`Xml`解析
部分`xml`文件会带有`命名空间`(`Namespace`),也就是冒号左侧的内容,此时我们需要在`go`结构体的`tag` 中加入`命名空间`
### 1.带有命名空间(Namespace)的`Xml`文件
```xml
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
<!-- yt即是命名空间 -->
<yt:videoId>XXXXXXX</yt:videoId>
<!-- media是另一个命名空间 -->
<media:community></media:community>
</feed>
```
### 2.针对命名空间构造结构体
```go
type Feed struct {
XMLName xml.Name `xml:"feed"` // 指定最外层的标签为feed
VideoId string `xml:"http://www.youtube.com/xml/schemas/2015 videoId"`
Community string `xml:"http://search.yahoo.com/mrss/ community"`
}
```
### 3.对`Xml`数据进行反序列化
```go
var feed Feed
// 读取Xml文件并返回字节流
content,err := ioutil.ReadFile(XmlFilename)
if err != nil {
log.Fatal(err)
}
// 将读取到的内容反序列化到feed
xml.Unmarshal(content,&feed)
```

View File

@@ -1,36 +0,0 @@
---
title: "Hugo使用指南"
slug: "hugo"
draft: false
date: 2022-05-20T10:23:53+08:00
description: "快速上手hugo"
tags:
- Go
- Hugo
categories:
- Go
---
查看Hugo版本号
```bash
hugo version
```
新建一个Hugo页面
```
hugo new site <siteName>
```
设置主题
```bash
cd <siteName>
git init
# 设置为 Stack主题
git clone https://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack
git submodule add https://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack
```
部署Hugo到github

View File

@@ -1,67 +0,0 @@
---
title: "Linux部署DHCP服务"
description: Debian下使用docker镜像部署DHCP服务
date: 2022-05-23T11:11:40+08:00
draft: false
slug: linux-dhcp
image:
categories: Linux
tags:
- Linux
- DHCP
---
拉取`networkboot/dhcpd`镜像
```shell
docker pull networkboot/dhcpd
```
新建`data/dhcpd.conf`文件
```shell
touch /data/dhcpd.conf
```
修改`data/dhcpd.conf`文件
```
subnet 204.254.239.0 netmask 255.255.255.224 {
option subnet-mask 255.255.0.0;
option domain-name "cname.nmslwsnd.com";
option domain-name-servers 8.8.8.8;
range 204.254.239.10 204.254.239.30;
}
```
修改`/etc/network/interfaces`
```
# The loopback network interface (always required)
auto lo
iface lo inet loopback
# Get our IP address from any DHCP server
auto dhcp
iface dhcp inet static
address 204.254.239.0
netmask 255.255.255.224
```
获取帮助命令
```shell
docker run -it --rm networkboot/dhcpd man dhcpd.conf
```
运行`DHCP`服务
```shell
docker run -it --rm --init --net host -v "/data":/data networkboot/dhcpd <网卡名称>
# 示例
docker run -it --rm --init --net host -v "/data":/data networkboot/dhcpd dhcp
```

View File

@@ -1,36 +0,0 @@
---
title: "Linux Shell"
description:
date: 2022-05-21T10:02:09+08:00
draft: false
Hidden: true
slug: linux-shell
image:
categories:
Linux
tag:
Linux
Shell
---
Linux守护进程:no_good:
```bash
#!/bin/bash
# nohup.sh
while true
do
# -f 后跟进程名,判断进程是否正在运行
if [ `pgrep -f <ProcessName> | wc -l` -eq 0 ];then
echo "进程已终止"
push
# /dev/null 无输出日志
nohup ./<ProcessName> > /dev/null 2>&1 &
else
echo "进程正在运行"
fi
# 每隔1分钟检查一次
sleep 1m
done
```

View File

@@ -1,65 +0,0 @@
---
title: "Linux"
description:
date: 2022-09-08T15:19:00+08:00
draft: true
slug: linux
image:
categories:
- Linux
tags:
- Linux
---
```bash
# 使用cd 进入到上一个目录
cd -
```
复制和粘贴
```bash
ctrl + shift + c
ctrl + shift + v
```
快速移动
```bash
# 移动到行首
ctrl + a
# 移动到行尾
ctrl + e
```
快速删除
```bash
# 删除光标之前的内容
ctrl + u
# 删除光标之后的内容
ctrl + k
# 恢复之前删除的内容
ctrl + y
```
不适用cat
```
使用less 查看 顶部的文件
less filename
```
使用alt+backspace删除,以单词为单位
```
tcpdump host 1.1.1.1
```
```
# 并行执行命令 Parallel
find . -type f -name '*.html' -print | parallel gzip
```

View File

@@ -0,0 +1,24 @@
---
title: 游黄山记(中)
slug: loco-rs-framework
description: 钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。
category: 古籍游记
post_type: article
pinned: false
status: published
visibility: public
noindex: false
tags:
- 钱谦益
- 黄山
- 游记
- 山水游记
---
# 游黄山记(中)
由祥符寺度石桥而北,逾慈光寺,行数里,径朱砂庵而上。过此取道钵盂、老人两峰之间,峰趾相并,两崖合遝,弥望削成。
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。

View File

@@ -1,569 +0,0 @@
---
title: "mysql个人常用命令及操作"
description:
date: 2021-09-21T16:13:24+08:00
draft: true
slug: mysql
image:
categories:
- Database
tags:
- Linux
- Mysql
- Sql
---
启动`mysql`
```bash
sudo service mysql start
```
使用`root`账户登录`mysql`
```bash
sudo mysql -u root
```
查看数据库信息
```mysql
show databases;
```
新增数据库
```mysql
create database <>;
# 示例新增一个名为gradesystem的数据库
create database gradesystem;
```
切换数据库
```mysql
use <>;
# 示例切换至gradesystem数据库
use gradesystem;
```
查看数据库中的表
```mysql
# 查看数据库中所有的表
show tables;
```
新增表
```mysql
# MySQL不区分大小写
CREATE TABLE student(
sid int NOT NULL AUTO_INCREMENT,
sname varchar(20) NOT NULL,
gender varchar(10) NOT NULL,
PRIMARY KEY(sid)
);
# 新增一个表名为学生的表。
# AUTO_INCREMENT, 自动地创建主键字段的值。
# PRIMARY KEY(sid) 设置主键为sid
CREATE TABLE course(
cid int not null auto_increment,
cname varchar(20) not null,
primary key(cid)
);
# 新增一个表名为课程的表。
# primary key(cid) 设置主键为cid
CREATE TABLE mark(
mid int not null auto_increment,
sid int not null,
cid int not null,
score int not null,
primary key(mid),
foreign key(sid) references student(sid),
foreign key(cid) references course(cid)
);
# 新增一个表明为mark的表
# primary key(cid) 设置主键为cid
# foreign 设置外键为sid
# foreign 设置外键为cid
insert into student values(1,'Tom','male'),(2,'Jack','male'),(3,'Rose','female');
# 向student表插入数据sid为1sname为'Tom',gender为'male'
insert into course values(1,'math'),(2,'physics'),(3,'chemistry');
# 向course表插入数据sid为1cname为'math'
insert into mark values(1,1,1,80);
# 向mark表插入数据mid为1sid为1,cid为1score为80
```
### 向数据库插入数据
```mysql
source <>
```
## SELECT语句查询
SELECT 语句的基本格式为:
```bash
SELECT 要查询的列名 FROM 表名字 WHERE 限制条件;
```
```mysql
select name,age from employee;
# 查看employee的name列和age列
select name,age from employee where age > 25;
# 筛选出age 大于25的结果
select name,age,phone from employee where name = 'Mary';
# 筛选出name为'Mary'的name,age,phone
select name,age,phone from employee where age < 25 or age >30;
# 筛选出age小于30或大于25的name,age,phone
select name,age,phone from employee where age > 25 and age < 30;
# 筛选出age大于25且小于30的name,age,phone
select name,age,phone from employee where age between 25 and 30;
# 筛选出包含25和30的name,age,phone
select name,age,phone,in_dpt from employee where in_dpt in('dpt3','dpt4');
# 筛选出在dpt3或dpt4里面的name,age,phone,in_dpt
select name,age,phone,in_dpt from employee where in_dpt not in('dpt1','dpt3');
# 筛选出不在dpt1和dpt3的name,age,phone,in_dpt
```
## 通配符
关键字 **LIKE** 可用于实现模糊查询,常见于搜索功能中。
和 LIKE 联用的通常还有通配符代表未知字符。SQL 中的通配符是 `_``%` 。其中 `_` 代表一个**未指定**字符,`%` 代表**不定个**未指定字符
```mysql
select name,age,phone from employee where phone like '1101__';
# 筛选出1101开头的六位数字的name,age,phone
select name,age,phone from employee where name like 'J%';
# 筛选出name位J开头的人的name,age,phone
```
## 排序
为了使查询结果看起来更顺眼,我们可能需要对结果按某一列来排序,这就要用到 **ORDER BY** 排序关键词。默认情况下,**ORDER BY** 的结果是**升序**排列,而使用关键词 **ASC****DESC** 可指定**升序**或**降序**排序。 比如,我们**按 salary 降序排列**SQL 语句为
```mysql
select name,age,salary,phone from employee order by salary desc;
# salary列按降序排列
select name,age,salary,phone from employee order by salary;
# 不加 DESC 或 ASC 将默认按照升序排列。
```
## SQL 内置函数和计算
置函数,这些函数都对 SELECT 的结果做操作:
| 函数名: | COUNT | SUM | AVG | MAX | MIN |
| -------- | ----- | ---- | -------- | ------ | ------ |
| 作用: | 计数 | 求和 | 求平均值 | 最大值 | 最小值 |
> 其中 COUNT 函数可用于任何数据类型(因为它只是计数),而 SUM 、AVG 函数都只能对数字类数据类型做计算MAX 和 MIN 可用于数值、字符串或是日期时间数据类型。
```mysql
select max(salary) as max_salary,min(salary) from employee;
# 使用as关键字可以给值重命名
```
## 连接查询
在处理多个表时,子查询只有在结果来自一个表时才有用。但如果需要显示两个表或多个表中的数据,这时就必须使用连接 **(join)** 操作。 连接的基本思想是把两个或多个表当作一个新的表来操作,如下:
```mysql
select id,name,people_num from employee,department where employee.in_dpt = department.dpt_name order by id;
# 这条语句查询出的是,各员工所在部门的人数,其中员工的 id 和 name 来自 employee 表people_num 来自 department 表:
select id,name,people_num from employee join department on employee.in_dpt = department.dpt_name order by id;
# 另一个连接语句格式是使用 JOIN ON 语法,刚才的语句等同于以上语句
```
## 删除数据库
```mysql
drop database test_01;
# 删除名为test_01的数据库;
```
### 修改表
重命名一张表的语句有多种形式,以下 3 种格式效果是一样的:
```sql
RENAME TABLE TO ;
ALTER TABLE RENAME ;
ALTER TABLE RENAME TO ;
```
进入数据库 mysql_shiyan
```mysql
use mysql_shiyan
```
使用命令尝试修改 `table_1` 的名字为 `table_2`
```mysql
RENAME TABLE table_1 TO table_2;
```
删除一张表的语句,类似于刚才用过的删除数据库的语句,格式是这样的:
```sql
DROP TABLE ;
```
比如我们把 `table_2` 表删除:
```mysql
DROP TABLE table_2;
```
#### 增加一列
在表中增加一列的语句格式为:
```sql
ALTER TABLE ADD COLUMN ;
ALTER TABLE ADD ;
```
现在 employee 表中有 `id、name、age、salary、phone、in_dpt` 这 6 个列,我们尝试加入 `height` (身高)一个列并指定 DEFAULT 约束:
```mysql
ALTER TABLE employee ADD height INT(4) DEFAULT 170;
```
可以发现:新增加的列,被默认放置在这张表的最右边。如果要把增加的列插入在指定位置,则需要在语句的最后使用 AFTER 关键词(**“AFTER 列 1” 表示新增的列被放置在 “列 1” 的后面**)。
> 提醒:语句中的 INT(4) 不是表示整数的字节数,而是表示该值的显示宽度,如果设置填充字符为 0则 170 显示为 0170
比如我们新增一列 `weight`(体重) 放置在 `age`(年龄) 的后面:
```mysql
ALTER TABLE employee ADD weight INT(4) DEFAULT 120 AFTER age;
```
上面的效果是把新增的列加在某位置的后面,如果想放在第一列的位置,则使用 `FIRST` 关键词,如语句:
```sql
ALTER TABLE employee ADD test INT(10) DEFAULT 11 FIRST;
```
#### 删除一列
删除表中的一列和刚才使用的新增一列的语句格式十分相似,只是把关键词 `ADD` 改为 `DROP` ,语句后面不需要有数据类型、约束或位置信息。具体语句格式:
```sql
ALTER TABLE DROP COLUMN ;
ALTER TABLE DROP ;
```
我们把刚才新增的 `test` 删除:
```sql
ALTER TABLE employee DROP test;
```
#### 重命名一列
这条语句其实不只可用于重命名一列,准确地说,它是对一个列做修改(CHANGE)
```sql
ALTER TABLE CHANGE ;
```
> **注意:这条重命名语句后面的 “数据类型” 不能省略,否则重命名失败。**
当**原列名**和**新列名**相同的时候,指定新的**数据类型**或**约束**,就可以用于修改数据类型或约束。需要注意的是,修改数据类型可能会导致数据丢失,所以要慎重使用。
我们用这条语句将 “height” 一列重命名为汉语拼音 “shengao” ,效果如下:
```mysql
ALTER TABLE employee CHANGE height shengao INT(4) DEFAULT 170;
```
#### 改变数据类型
要修改一列的数据类型,除了使用刚才的 **CHANGE** 语句外,还可以用这样的 **MODIFY** 语句:
```sql
ALTER TABLE MODIFY ;
```
再次提醒,修改数据类型必须小心,因为这可能会导致数据丢失。在尝试修改数据类型之前,请慎重考虑。
#### 修改表中某个值
大多数时候我们需要做修改的不会是整个数据库或整张表,而是表中的某一个或几个数据,这就需要我们用下面这条命令达到精确的修改:
```sql
UPDATE SET 1=1,2=2 WHERE ;
```
比如,我们要把 Tom 的 age 改为 21salary 改为 3000
```mysql
UPDATE employee SET age=21,salary=3000 WHERE name='Tom';
```
> **注意:一定要有 WHERE 条件,否则会出现你不想看到的后果**
#### 删除一行记录
删除表中的一行数据,也必须加上 WHERE 条件,否则整列的数据都会被删除。删除语句:
```sql
DELETE FROM WHERE ;
```
我们尝试把 Tom 的数据删除:
```mysql
DELETE FROM employee WHERE name='Tom';
```
#### 索引
索引是一种与表有关的结构,它的作用相当于书的目录,可以根据目录中的页码快速找到所需的内容。
当表中有大量记录时,若要对表进行查询,没有索引的情况是全表搜索:将所有记录一一取出,和查询条件进行对比,然后返回满足条件的记录。这样做会执行大量磁盘 I/O 操作,并花费大量数据库系统时间。
而如果在表中已建立索引,在索引中找到符合查询条件的索引值,通过索引值就可以快速找到表中的数据,可以**大大加快查询速度**。
对一张表中的某个列建立索引,有以下两种语句格式:
```sql
ALTER TABLE ADD INDEX ();
CREATE INDEX ON ();
```
我们用这两种语句分别建立索引:
```sql
ALTER TABLE employee ADD INDEX idx_id (id); #employee表的id列上建立名为idx_id的索引
CREATE INDEX idx_name ON employee (name); #employee表的name列上建立名为idx_name的索引
```
索引的效果是加快查询速度,当表中数据不够多的时候是感受不出它的效果的。这里我们使用命令 **SHOW INDEX FROM 表名字;** 查看刚才新建的索引:
![01](https://doc.shiyanlou.com/MySQL/sql-06-01.png)
在使用 SELECT 语句查询的时候,语句中 WHERE 里面的条件,会**自动判断有没有可用的索引**。
比如有一个用户表,它拥有用户名(username)和个人签名(note)两个字段。其中用户名具有唯一性,并且格式具有较强的限制,我们给用户名加上一个唯一索引;个性签名格式多变,而且允许不同用户使用重复的签名,不加任何索引。
这时候,如果你要查找某一用户,使用语句 `select * from user where username=?``select * from user where note=?` 性能是有很大差距的,对**建立了索引的用户名**进行条件查询会比**没有索引的个性签名**条件查询快几倍,在数据量大的时候,这个差距只会更大。
一些字段不适合创建索引,比如性别,这个字段存在大量的重复记录无法享受索引带来的速度加成,甚至会拖累数据库,导致数据冗余和额外的 CPU 开销。
## 视图
视图是从一个或多个表中导出来的表,是一种**虚拟存在的表**。它就像一个窗口,通过这个窗口可以看到系统专门提供的数据,这样,用户可以不用看到整个数据库中的数据,而只关心对自己有用的数据。
注意理解视图是虚拟的表:
- 数据库中只存放了视图的定义,而没有存放视图中的数据,这些数据存放在原来的表中;
- 使用视图查询数据时,数据库系统会从原来的表中取出对应的数据;
- 视图中的数据依赖于原来表中的数据,一旦表中数据发生改变,显示在视图中的数据也会发生改变;
- 在使用视图的时候,可以把它当作一张表。
创建视图的语句格式为:
```sql
CREATE VIEW (a,b,c) AS SELECT 1,2,3 FROM ;
```
可见创建视图的语句,后半句是一个 SELECT 查询语句,所以**视图也可以建立在多张表上**,只需在 SELECT 语句中使用**子查询**或**连接查询**,这些在之前的实验已经进行过。
现在我们创建一个简单的视图,名为 **v_emp**,包含**v_name****v_age****v_phone**三个列:
```sql
CREATE VIEW v_emp (v_name,v_age,v_phone) AS SELECT name,age,phone FROM employee;
```
![02](https://doc.shiyanlou.com/MySQL/sql-06-02.png)
## 导出
导出与导入是相反的过程,是把数据库某个表中的数据保存到一个文件之中。导出语句基本格式为:
```sql
SELECT 12 INTO OUTFILE '文件路径和文件名' FROM ;
```
**注意:语句中 “文件路径” 之下不能已经有同名文件。**
现在我们把整个 employee 表的数据导出到 /var/lib/mysql-files/ 目录下,导出文件命名为 **out.txt** 具体语句为:
```sql
SELECT * INTO OUTFILE '/var/lib/mysql-files/out.txt' FROM employee;
```
用 gedit 可以查看导出文件 `/var/lib/mysql-files/out.txt` 的内容:
> 也可以使用 `sudo cat /var/lib/mysql-files/out.txt` 命令查看。
## 备份
数据库中的数据十分重要,出于安全性考虑,在数据库的使用中,应该注意使用备份功能。
> 备份与导出的区别:导出的文件只是保存数据库中的数据;而备份,则是把数据库的结构,包括数据、约束、索引、视图等全部另存为一个文件。
**mysqldump** 是 MySQL 用于备份数据库的实用程序。它主要产生一个 SQL 脚本文件,其中包含从头重新创建数据库所必需的命令 CREATE TABLE INSERT 等。
使用 mysqldump 备份的语句:
```bash
mysqldump -u root 数据库名>备份文件名; #备份整个数据库
mysqldump -u root 数据库名 表名字>备份文件名; #备份整个表
```
> mysqldump 是一个备份工具,因此该命令是在终端中执行的,而不是在 mysql 交互环境下
我们尝试备份整个数据库 `mysql_shiyan`,将备份文件命名为 `bak.sql`,先 `Ctrl+D` 退出 MySQL 控制台,再打开 Xfce 终端,在终端中输入命令:
```bash
cd /home/shiyanlou/
mysqldump -u root mysql_shiyan > bak.sql;
```
使用命令 “ls” 可见已经生成备份文件 `bak.sql`
![07](https://doc.shiyanlou.com/MySQL/sql-06-07.png)
> 你可以用 gedit 查看备份文件的内容,可以看见里面不仅保存了数据,还有所备份的数据库的其它信息。
## 恢复
用备份文件恢复数据库,其实我们早就使用过了。在本次实验的开始,我们使用过这样一条命令:
```bash
source /tmp/SQL6/MySQL-06.sql
```
这就是一条恢复语句,它把 MySQL-06.sql 文件中保存的 `mysql_shiyan` 数据库恢复。
还有另一种方式恢复数据库,但是在这之前我们先使用命令新建一个**空的数据库 test**
```bash
mysql -u root #因为在上一步已经退出了 MySQL现在需要重新登录
CREATE DATABASE test; #新建一个名为test的数据库
```
再次 **Ctrl+D** 退出 MySQL然后输入语句进行恢复把刚才备份的 **bak.sql** 恢复到 **test** 数据库:
```bash
mysql -u root test < bak.sql
```
我们输入命令查看 test 数据库的表,便可验证是否恢复成功:
```bash
mysql -u root # 因为在上一步已经退出了 MySQL现在需要重新登录
use test # 连接数据库 test
SHOW TABLES; # 查看 test 数据库的表
```
可以看见原数据库的 4 张表和 1 个视图,现在已经恢复到 test 数据库中:
![08](https://doc.shiyanlou.com/MySQL/sql-06-08.png)
再查看 employee 表的恢复情况:
![09](https://doc.shiyanlou.com/MySQL/sql-06-09.png)
## Mysql授权
1. 登录MySQL
```sql
mysql -u root -p
```
2. 进入MySQL并查看用户和主机
```sql
use mysql;
select host,user from user;
```
3. 更新root用户允许远程连接
```sql
update user set host='%' where user='root';
```
4. 设置root用户密码
```sql
alter user 'root'@'localhost' identified by 'your_password';
```
注意:不要使用临时密码。
5. 授权允许远程访问:
```sql
grant all privileges on *.* to 'root'@'%' identified by 'password';
```
请将命令中的“password”更改为您的MySQL密码。
6. 刷新授权:
```sql
flush privileges;
```
7. 关闭授权:
```sql
revoke all on *.* from dba@localhost;
```
8. 查看MySQL初始密码
```bash
grep "password" /var/log/mysqld.log
```
通过以上操作您的MySQL可以被远程连接并进行管理。请注意在授权和更新用户权限时应只授权特定的数据库或表格而不是使用通配符以提高安全性和减少不必要的权限。在进行远程访问授权时应只授权特定的IP地址或IP地址段而不是使用通配符以减少潜在的安全威胁。同时建议使用强密码并定期更换密码以提高安全性。

View File

@@ -1,116 +0,0 @@
---
title: "Redis常用命令"
description:
date: 2022-04-21T09:42:24+08:00
draft: false
slug: redis
image:
categories:
- Database
tags:
- Database
- Redis
---
# 安装`Redis`
## `Debian`下安装`Redis`服务端
```bash
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
sudo apt-get update
sudo apt-get install redis
```
## `Windows`下安装`Redis` 第三方`GUI`客户端
Redis (GUI)管理客户端
```bash
winget install qishibo.AnotherRedisDesktopManager
```
## `Redis`修改监听端口
```bash
vim /etc/redis/redis.conf
```
# `Redis`常用命令
## `bitMap`
使用`BitMap`实现签到,`setbit key offset value,` `key`做为时间,`offset`做为用户`id` ,`value`做为签到状态
```shell
# 示例
setbit key offset value key
# 设置用户10086在2022/04/21进行签到
setbit check_in_2022_04_21 10086 1
# 获取用户10086是否在2022/04/21签到
getbit check_in_2022_04_21 10086
# bitcount 获取20220421签到的用户数量
# 可选 start和end参数
# start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位
BITCOUNT 20220421
# BITOP 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上
# operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种:
# BITOP AND destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。
# BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。
# BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。
# BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。
# 除了 NOT 操作之外,其他操作都可以接受一个或多个 key 作为输入。
BITOP AND and-result 20220421 20220420
GETBIT and-result
```
## `Redis` 消息队列
```
# LPUSH key value, Lpush用于生产并添加消息
# LPOP key,用于取出消息
```
## `Lrem`
```shell
# count > 0 : 从表头开始向表尾搜索,移除与 VALUE 相等的元素,数量为 COUNT 。
# count < 0 : 从表尾开始向表头搜索,移除与 VALUE 相等的元素,数量为 COUNT 的绝对值。
# count = 0 : 移除表中所有与 VALUE 相等的值。
LREM key count VALUE
```
## `Pipeline`
`Redis` 使用的是客户端-服务器(`CS`)模型和请求/响应协议的 TCP 服务器。这意味着通常情况下一个请求会遵循以下步骤:
客户端向服务端发送一个查询请求,并监听 Socket 返回,通常是以阻塞模式,等待服务端响应。
服务端处理命令,并将结果返回给客户端。
管道(`pipeline`可以一次性发送多条命令并在执行完后一次性将结果返回pipeline 通过减少客户端与 redis 的通信次数来实现降低往返延时时间,而且 `Pipeline` 实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。
通俗点:`pipeline`就是把一组命令进行打包然后一次性通过网络发送到Redis。同时将执行的结果批量的返回回来
```go
// 使用 go-redis
p := Client.Pipeline()
for _, v := range val {
p.LRem("user:watched:"+guid, 0, v)
}
// p.Exec()执行pipeline 请求
p.Exec()
```
[本文参考](https://blog.csdn.net/mumuwang1234/article/details/118603697)

View File

@@ -1,169 +0,0 @@
---
title: "手把手教你用Rust进行Dll注入"
description: 我是一个懒惰的男孩,我甚至懒的不想按键盘上的按键和挪动鼠标.可是我还是想玩游戏,该怎么做呢?通过 google 了解到我可以通过将我自己编写的dll文件注入到目标程序内,来实现这个事情.
date: 2022-09-17T15:10:26+08:00
draft: false
slug: rust-dll
image:
categories:
- Rust
tags:
- Rust
- Dll
---
# 前言
我是一个懒惰的男孩,我甚至懒的不想按键盘上的按键和挪动鼠标.可是我还是想玩游戏,该怎么做呢?
通过google了解到我可以通过将我自己编写的 `dll` 文件注入到目标程序内,来实现这个事情.
将大象放在冰箱里需要几步?
答案是三步。
# `snes9x` 模拟器 `Dll` 注入实战
## 一、现在我们需要进行第一步,生成 `Dll` 文件
准确说是我们需要生成符合 `C` 标准的 `dll` 文件,如果你使用 `go` 语言,直接使用 `Cgo``C` 进行互动,即可生成符合 `C` 标准的 `dll` .
但是很明显,我要用 `Rust` 来做这件事。
由于 `Rust` 拥有出色的所有权机制,和其他语言的交互会导致 `Rust` 失去这个特性,所以这一块是属于 `Unsafe` 区域的。
`Rust` 默认生成的 `Dll` 是提供给 `Rust` 语言来调用的,而非C系语言的 `dll`.
我们现在来生成 `C` 系语言的 `Dll` 吧。
### 1.新建项目 `lib` 目录 `lib` 目录主要作为库文件以方便其他开发者调用
```bash
# 新建库项目
Cargo new --lib <project name>
Cargo new --lib joy
```
### 2.修改 `Cargo.toml` 文件 增加 `bin` 区域
```toml
[package]
name = "joy"
version = "0.1.0"
edition = "2021"
[lib]
name = "joy"
path = "src/lib.rs"
crate-type = ["cdylib"]
[[bin]]
name = "joyrun"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
```
```bash
# 为项目导入依赖ctor来生成符合c标准的dll
cargo add ctor
```
### 3.修改 `lib.rs` 使用 `ctor`
```rust
// lib.rs
#[ctor::ctor]
fn ctor() {
println!("我是一个dll")
}
```
#### 4.编译项目生成 `joy.dll` 以及 `joyrun.exe`
```bash
cargo build
```
现在我们有了我们自己的 `dll` 文件,该如何将他注入到目标的进程呢?
## 二、使用 `dll-syringe` 进行dll注入
```
cargo add dll-syringe
```
### 1.修改main.rs 将刚刚编写的dll注入到目标应用
```rust
// main.rs
use dll_syringe::{Syringe, process::OwnedProcess};
fn main() {
// 通过进程名找到目标进程
let target_process = OwnedProcess::find_first_by_name("snes9x").unwrap();
// 新建一个注入器
let syringe = Syringe::for_process(target_process);
// 将我们刚刚编写的dll加载进去
let injected_payload = syringe.inject("joy.dll").unwrap();
// do something else
// 将我们刚刚注入的dll从目标程序内移除
syringe.eject(injected_payload).unwrap();
}
```
### 2.运行项目
```shell
# 运行项目
cargo run
```
此时你可能会遇到一个新问题,我的`dll`已经加载进目标程序了,为什么没有打印 "我是一个dll"
### 3.解决控制台无输出问题
这是由于目标程序没有控制台,所以我们没有看到 `dll` 的输出,接下来让我们来获取 `dll` 的输出。
此时我们可以使用 `TCP` 交互的方式或采用 `OutputDebugStringA function (debugapi.h)` 来进行打印
`OutputDebugStringA` ,需要额外开启`features` `Win32_System_Diagnostics_Debug`
```rust
// Rust Unsafe fn
// windows::Win32::System::Diagnostics::Debug::OutputDebugStringA
pub unsafe fn OutputDebugStringA<'a, P0>(lpoutputstring: P0)
where
P0: Into<PCSTR>,
// Required features: "Win32_System_Diagnostics_Debug"
```
采用 `Tcp` 通信交互
```rust
// 在lib.rs 新建tcp客户端
let stream = TcpStream::connect("127.0.0.1:7331").unwrap();
```
```rust
// 在main.rs 新建tcp服务端
let (mut stream, addr) = listener.accept()?;
info!(%addr,"Accepted!");
let mut buf = vec![0u8; 1024];
let mut stdout = std::io::stdout();
while let Ok(n) = stream.read(&mut buf[..]) {
if n == 0 {
break;
}
stdout.write_all(&buf[..n])?
}
```
```shell
# 运行项目
cargo run
# 运行之后,大功告成,成功在Tcp服务端看到了,客户端对我们发起了请求。
```

View File

@@ -0,0 +1,24 @@
---
title: 徐霞客游记·游恒山日记
slug: rust-programming-tips
description: 游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。
category: 古籍游记
post_type: article
pinned: false
status: published
visibility: public
noindex: false
tags:
- 徐霞客
- 恒山
- 悬空寺
- 山水游记
---
# 徐霞客游记·游恒山日记
出南山。大溪从山中俱来者,别而西去。余北驰平陆中,望外界之山,高不及台山十之四,其长缭绕如垣。
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。

View File

@@ -1,96 +0,0 @@
---
title: "Rust使用Serde进行序列化及反序列化"
description: 这篇文章将介绍如何在Rust编程语言中使用Serde库进行序列化和反序列化操作。Serde是一个广泛使用的序列化和反序列化库能够支持JSON、BSON、CBOR、MessagePack和YAML等常见数据格式。
date: 2022-07-25T14:02:22+08:00
draft: false
slug: rust-serde
image:
categories:
- Rust
tags:
- Rust
- Xml
---
# 开始之前
```toml
# 在Cargo.toml 新增以下依赖
[dependencies]
serde = { version = "1.0.140",features = ["derive"] }
serde_json = "1.0.82"
serde_yaml = "0.8"
serde_urlencoded = "0.7.1"
# 使用yaserde解析xml
yaserde = "0.8.0"
yaserde_derive = "0.8.0"
```
## `Serde`通用规则(`json`,`yaml`,`xml`)
### 1.使用`Serde`宏通过具体结构实现序列化及反序列化
```rust
use serde::{Deserialize, Serialize};
// 为结构体实现 Serialize(序列化)属性和Deserialize(反序列化)
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Person {
// 将该字段名称修改为lastname
#[serde(rename = "lastname")]
name: String,
// 反序列化及序列化时忽略该字段(nickname)
#[serde(skip)]
nickname: String,
// 分别设置序列化及反序列化时输出的字段名称
#[serde(rename(serialize = "serialize_id", deserialize = "derialize_id"))
id: i32,
// 为age设置默认值
#[serde(default)]
age: i32,
}
```
### 2.使用`serde_json`序列化及反序列化
```rust
use serde_json::{json, Value};
let v:serde_json::Value = json!(
{
"x":20.0,
"y":15.0
}
);
println!("x:{:#?},y:{:#?}",v["x"],v["y"]); // x:20.0, y:15.0
```
### 3.使用`Serde`宏统一格式化输入、输出字段名称
| 方法名 | 方法效果 |
| ------------------------------- | ------------------------------------------------------------ |
| `PascalCase` | 首字母为大写的驼峰式命名,推荐结构体、枚举等名称以及`Yaml`配置文件读取使用。 |
| `camelCase` | 首字母为小写的驼峰式命名,推荐`Yaml`配置文件读取使用。 |
| `snake_case` | 小蛇形命名,用下划线"`_`"连接单词,推荐函数命名以及变量名称使用此种方式。 |
| `SCREAMING_SNAKE_CASE` | 大蛇形命名,单词均为大写形式,用下划线"`_`"连接单词。推荐常数及全局变量使用此种方式。 |
| `kebab-case`(小串烤肉) | 同`snake_case`,使用中横线"`-`"替换了下划线"`_`"。 |
| `SCREAMING-KEBAB-CAS`(大串烤肉) | 同`SCREAMING_SNAKE_CASE`,使用中横线"`-`"替换了下划线"`_`"。 |
示例:
```rust
pub struct App {
#[serde(rename_all = "PascalCase")]
/// 统一格式化输入、输出字段名称
/// #[serde(rename_all = "camelCase")]
/// #[serde(rename_all = "snake_case")]
/// #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
/// 仅设置
version: String,
app_name: String,
host: String,
}
```
[本文参考:yaserde](https://github.com/media-io/yaserde)
[本文参考:magiclen](https://magiclen.org/rust-serde/)

View File

@@ -1,37 +0,0 @@
---
title: "Rust Sqlx"
description:
date: 2022-08-29T13:55:08+08:00
draft: true
slug: rust-sqlx
image:
categories:
-
tags:
-
---
# sqlx-cli
## 创建 migration
```shell
sqlx migrate add categories
```
```sql
-- Add migration script here
CREATE TABLE IF NOT EXISTS categories(
id INT PRIMARY KEY DEFAULT AUTO_INCREMENT,
type_id INT UNIQUE NOT NULL,
parent_id INT NOT NULL,
name TEXT UNIQUE NOT NULL,
);
```
## 运行 migration
```sh
sqlx migrate run
```

View File

@@ -0,0 +1,24 @@
---
title: 游黄山记(上)
slug: terminal-ui-design
description: 钱谦益《游黄山记》上篇,包含序、记之一与记之二。
category: 古籍游记
post_type: article
pinned: false
status: published
visibility: public
noindex: false
tags:
- 钱谦益
- 黄山
- 游记
- 山水游记
---
# 游黄山记(上)
辛巳春,余与程孟阳订黄山之游,约以梅花时相寻于武林之西溪。徐维翰书来劝驾,读之两腋欲举,遂挟吴去尘以行。
黄山耸秀峻极,作镇一方。江南诸山,天台、天目为最,以地形准之,黄山之趾与二山齐。
自山口至汤口,山之麓也,登山之径于是始。汤泉之流,自紫石峰六百仞县布,其下有香泉溪。

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