Compare commits
8 Commits
d18a709987
...
43eaaf3602
| Author | SHA1 | Date | |
|---|---|---|---|
| 43eaaf3602 | |||
| 313f174fbc | |||
| a9a05aa105 | |||
| 99b308e800 | |||
| 92a85eef20 | |||
| 84f82c2a7e | |||
| 178434d63e | |||
| ec96d91548 |
183
.gitea/workflows/backend-docker.yml
Normal file
183
.gitea/workflows/backend-docker.yml
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
name: docker-images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- backend/**
|
||||||
|
- frontend/**
|
||||||
|
- admin/**
|
||||||
|
- deploy/docker/**
|
||||||
|
- .gitea/workflows/backend-docker.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
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: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve image metadata
|
||||||
|
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 "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
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }}
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
|
||||||
|
echo "Missing secrets: REGISTRY_USERNAME / REGISTRY_TOKEN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" --username "${REGISTRY_USER}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
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 }}
|
||||||
|
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 build \
|
||||||
|
--file "${DOCKERFILE}" \
|
||||||
|
"${BUILD_ARGS[@]}" \
|
||||||
|
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
|
||||||
|
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
|
||||||
|
--tag "${IMAGE_BASE}:${TAG_SHA}" \
|
||||||
|
"${CONTEXT_DIR}"
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker push "${IMAGE_BASE}:${TAG_LATEST}"
|
||||||
|
docker push "${IMAGE_BASE}:${TAG_BRANCH}"
|
||||||
|
docker push "${IMAGE_BASE}:${TAG_SHA}"
|
||||||
|
|
||||||
|
- name: Output image tags
|
||||||
|
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}"
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -5,7 +5,24 @@
|
|||||||
frontend/.astro/
|
frontend/.astro/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
admin/dist/
|
||||||
|
admin/node_modules/
|
||||||
|
mcp-server/node_modules/
|
||||||
|
|
||||||
backend/target/
|
backend/target/
|
||||||
backend/.loco-start.err.log
|
backend/.loco-start.err.log
|
||||||
backend/.loco-start.out.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.*
|
||||||
|
lighthouse-*/
|
||||||
|
lighthouse-*.json
|
||||||
|
|||||||
148
README.md
148
README.md
@@ -6,47 +6,81 @@ Monorepo for the Termi blog system.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
|
├─ admin/ # React + shadcn admin workspace
|
||||||
├─ frontend/ # Astro blog frontend
|
├─ frontend/ # Astro blog frontend
|
||||||
├─ backend/ # Loco.rs backend and 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
|
├─ .codex/ # Codex workspace config
|
||||||
└─ .vscode/ # Editor workspace config
|
└─ .vscode/ # Editor workspace config
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
### Monorepo scripts
|
### Recommended
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts `frontend + admin + backend` in a single Windows Terminal window with multiple tabs.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### PowerShell entrypoint
|
||||||
|
|
||||||
|
If you prefer direct scripts, use the single root entrypoint:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\dev.ps1
|
.\dev.ps1
|
||||||
|
.\dev.ps1 -WithMcp
|
||||||
|
.\dev.ps1 -Only frontend
|
||||||
|
.\dev.ps1 -Only admin
|
||||||
|
.\dev.ps1 -Only backend
|
||||||
|
.\dev.ps1 -Only mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
Only frontend:
|
If you want a single service to be opened as a new Windows Terminal tab instead of running in the current shell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\dev.ps1 -FrontendOnly
|
.\dev.ps1 -Only frontend -Spawn
|
||||||
```
|
```
|
||||||
|
|
||||||
Only backend:
|
Legacy aliases are still available and now just forward to `dev.ps1`:
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\dev.ps1 -BackendOnly
|
|
||||||
```
|
|
||||||
|
|
||||||
Direct scripts:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\start-frontend.ps1
|
.\start-frontend.ps1
|
||||||
.\start-backend.ps1
|
.\start-backend.ps1
|
||||||
|
.\start-admin.ps1
|
||||||
|
.\start-mcp.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
pnpm install
|
||||||
npm run dev
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd admin
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
@@ -57,6 +91,94 @@ $env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-ap
|
|||||||
cargo loco start 2>&1
|
cargo loco start 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
- 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`)
|
||||||
|
|
||||||
|
### MCP Server
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dev.ps1 -Only mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Default MCP endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5151/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local development API key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
termi-mcp-local-dev-key
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP server wraps real backend APIs for:
|
||||||
|
|
||||||
|
- Listing, reading, creating, updating, and deleting Markdown posts
|
||||||
|
- Listing, creating, updating, and deleting categories
|
||||||
|
- Listing, creating, updating, and deleting tags
|
||||||
|
- Reading and updating public site settings
|
||||||
|
- Rebuilding the AI index
|
||||||
|
|
||||||
## Repo Name
|
## Repo Name
|
||||||
|
|
||||||
Recommended repository name: `termi-blog`
|
Recommended repository name: `termi-blog`
|
||||||
|
|||||||
5
admin/.dockerignore
Normal file
5
admin/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
24
admin/.gitignore
vendored
Normal file
24
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
31
admin/Dockerfile
Normal file
31
admin/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
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;"]
|
||||||
24
admin/docker-entrypoint.d/40-runtime-config.sh
Normal file
24
admin/docker-entrypoint.d/40-runtime-config.sh
Normal 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
|
||||||
23
admin/eslint.config.js
Normal file
23
admin/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
24
admin/index.html
Normal file
24
admin/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Termi Admin is the new React and shadcn-based control room for the blog system."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Termi Admin</title>
|
||||||
|
</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
63
admin/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3515
admin/package-lock.json
generated
Normal file
3515
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
admin/package.json
Normal file
44
admin/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 4322",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"marked": "^17.0.5",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
2304
admin/pnpm-lock.yaml
generated
Normal file
2304
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
admin/public/favicon.svg
Normal file
9
admin/public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 B |
1
admin/public/runtime-config.js
Normal file
1
admin/public/runtime-config.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {}
|
||||||
389
admin/src/App.tsx
Normal file
389
admin/src/App.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
startTransition,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
BrowserRouter,
|
||||||
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useNavigate,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
import { LoaderCircle } from 'lucide-react'
|
||||||
|
import { Toaster, toast } from 'sonner'
|
||||||
|
|
||||||
|
import { AppShell } from '@/components/app-shell'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import type { AdminSessionResponse } from '@/lib/types'
|
||||||
|
import { LoginPage } from '@/pages/login-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 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 AuditPage = lazy(async () => {
|
||||||
|
const mod = await import('@/pages/audit-page')
|
||||||
|
return { default: mod.AuditPage }
|
||||||
|
})
|
||||||
|
const SubscriptionsPage = lazy(async () => {
|
||||||
|
const mod = await import('@/pages/subscriptions-page')
|
||||||
|
return { default: mod.SubscriptionsPage }
|
||||||
|
})
|
||||||
|
|
||||||
|
type SessionContextValue = {
|
||||||
|
session: AdminSessionResponse
|
||||||
|
setSession: (session: AdminSessionResponse) => void
|
||||||
|
refreshSession: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue | null>(null)
|
||||||
|
|
||||||
|
function useSession() {
|
||||||
|
const context = useContext(SessionContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSession must be used inside SessionContext')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppLoadingScreen() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
|
||||||
|
<div className="flex max-w-md flex-col items-center gap-4 rounded-3xl border border-border/70 bg-card/80 px-8 py-10 text-center shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
|
<LoaderCircle className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs uppercase tracking-[0.32em] text-muted-foreground">
|
||||||
|
Termi 后台
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">正在进入管理后台</h1>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
正在检查当前登录状态,并准备新的 React 管理工作台。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if (!session.authenticated) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicOnly() {
|
||||||
|
const { session, setSession } = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
if (session.authenticated) {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginPage
|
||||||
|
submitting={submitting}
|
||||||
|
localLoginEnabled={session.local_login_enabled}
|
||||||
|
proxyAuthEnabled={session.proxy_auth_enabled}
|
||||||
|
onLogin={async (payload) => {
|
||||||
|
try {
|
||||||
|
setSubmitting(true)
|
||||||
|
const nextSession = await adminApi.login(payload)
|
||||||
|
startTransition(() => {
|
||||||
|
setSession(nextSession)
|
||||||
|
})
|
||||||
|
toast.success('后台登录成功。')
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '当前无法登录后台。')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedLayout() {
|
||||||
|
const { session, setSession } = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false)
|
||||||
|
|
||||||
|
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)
|
||||||
|
const nextSession = await adminApi.logout()
|
||||||
|
startTransition(() => {
|
||||||
|
setSession(nextSession)
|
||||||
|
})
|
||||||
|
toast.success('已退出后台。')
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '当前无法退出后台。')
|
||||||
|
} finally {
|
||||||
|
setLoggingOut(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<PublicOnly />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<ProtectedLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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="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="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nextSession = await adminApi.sessionStatus()
|
||||||
|
startTransition(() => {
|
||||||
|
setSession(nextSession)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof ApiError ? error.message : '当前无法连接后台会话接口。',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshSession()
|
||||||
|
}, [refreshSession])
|
||||||
|
|
||||||
|
const contextValue = useMemo<SessionContextValue>(
|
||||||
|
() => ({
|
||||||
|
session,
|
||||||
|
setSession,
|
||||||
|
refreshSession,
|
||||||
|
}),
|
||||||
|
[session, refreshSession],
|
||||||
|
)
|
||||||
|
|
||||||
|
const basename =
|
||||||
|
((import.meta.env.VITE_ADMIN_BASENAME as string | undefined)?.trim() || '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
) || undefined
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppLoadingScreen />
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider value={contextValue}>
|
||||||
|
<BrowserRouter basename={basename}>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
|
</SessionContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
273
admin/src/components/app-shell.tsx
Normal file
273
admin/src/components/app-shell.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
BellRing,
|
||||||
|
BookOpenText,
|
||||||
|
ExternalLink,
|
||||||
|
History,
|
||||||
|
Image as ImageIcon,
|
||||||
|
LayoutDashboard,
|
||||||
|
Link2,
|
||||||
|
LogOut,
|
||||||
|
MessageSquareText,
|
||||||
|
Orbit,
|
||||||
|
ScrollText,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
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 = [
|
||||||
|
{
|
||||||
|
to: '/',
|
||||||
|
label: '概览',
|
||||||
|
description: '站点运营总览',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/analytics',
|
||||||
|
label: '数据分析',
|
||||||
|
description: '搜索词与 AI 问答洞察',
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/posts',
|
||||||
|
label: '文章',
|
||||||
|
description: 'Markdown 内容管理',
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/revisions',
|
||||||
|
label: '版本',
|
||||||
|
description: '历史快照与一键回滚',
|
||||||
|
icon: History,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/comments',
|
||||||
|
label: '评论',
|
||||||
|
description: '审核与段落回复',
|
||||||
|
icon: MessageSquareText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/friend-links',
|
||||||
|
label: '友链',
|
||||||
|
description: '友链申请与互链管理',
|
||||||
|
icon: Link2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/reviews',
|
||||||
|
label: '评测',
|
||||||
|
description: '评测内容库',
|
||||||
|
icon: BookOpenText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/media',
|
||||||
|
label: '媒体库',
|
||||||
|
description: '对象存储图片管理',
|
||||||
|
icon: ImageIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/subscriptions',
|
||||||
|
label: '订阅',
|
||||||
|
description: '邮件 / Webhook 推送',
|
||||||
|
icon: BellRing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/audit',
|
||||||
|
label: '审计',
|
||||||
|
description: '后台操作审计日志',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/settings',
|
||||||
|
label: '设置',
|
||||||
|
description: '品牌、资料与 AI 配置',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-[1600px] gap-6 px-4 py-4 lg:px-6 lg:py-6">
|
||||||
|
<aside className="hidden w-[310px] shrink-0 lg:block">
|
||||||
|
<div className="sticky top-6 overflow-hidden rounded-[2rem] border border-border/70 bg-card/90 shadow-[0_32px_90px_rgba(15,23,42,0.14)] backdrop-blur">
|
||||||
|
<div className="space-y-5 p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
|
||||||
|
<Orbit className="h-3.5 w-3.5" />
|
||||||
|
Termi 后台
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">博客系统控制台</h1>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
一个独立的 React 管理工作台,用来处理发布、审核、运营以及站内 AI 配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{primaryNav.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'group flex items-start gap-3 rounded-2xl border px-4 py-3 transition-all',
|
||||||
|
isActive
|
||||||
|
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.14)]'
|
||||||
|
: 'border-transparent bg-background/50 hover:border-border/80 hover:bg-accent/55',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex h-10 w-10 items-center justify-center rounded-xl border',
|
||||||
|
isActive
|
||||||
|
? 'border-primary/25 bg-primary/12 text-primary'
|
||||||
|
: 'border-border/80 bg-secondary text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium">{item.label}</div>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="rounded-[1.7rem] border border-border/70 bg-background/65 p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
工作台状态
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
核心后台流程统一运行在当前独立管理端。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="success">运行中</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-2">
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
前台站点与后台管理保持解耦。
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
后端继续作为统一认证与数据层。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-6">
|
||||||
|
<header className="sticky top-4 z-20 rounded-[1.8rem] border border-border/70 bg-card/80 px-5 py-4 shadow-[0_20px_60px_rgba(15,23,42,0.1)] backdrop-blur">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-secondary-foreground">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
新版管理工作台
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
当前登录:{username ?? 'admin'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="flex gap-2 overflow-x-auto lg:hidden">
|
||||||
|
{primaryNav.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'rounded-full border px-3 py-2 text-sm whitespace-nowrap transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'border-primary/30 bg-primary/10 text-primary'
|
||||||
|
: 'border-border/70 bg-background/60 text-muted-foreground',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={buildFrontendUrl('/')} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
打开前台
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void onLogout()}
|
||||||
|
disabled={loggingOut || !canLogout}
|
||||||
|
title={canLogout ? undefined : '当前会话由前置 SSO / 代理控制'}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{canLogout ? (loggingOut ? '退出中...' : '退出登录') : 'SSO 受代理保护'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
admin/src/components/form-field.tsx
Normal file
21
admin/src/components/form-field.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
export function FormField({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{hint ? <p className="text-xs leading-5 text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
admin/src/components/lazy-monaco.tsx
Normal file
72
admin/src/components/lazy-monaco.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
admin/src/components/markdown-preview.tsx
Normal file
41
admin/src/components/markdown-preview.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { useDeferredValue, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type MarkdownPreviewProps = {
|
||||||
|
markdown: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
|
||||||
|
const deferredMarkdown = useDeferredValue(markdown)
|
||||||
|
const html = useMemo(() => {
|
||||||
|
const rendered = marked.parse(deferredMarkdown || '暂无内容。')
|
||||||
|
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
|
||||||
|
}, [deferredMarkdown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
'mx-auto max-w-4xl px-8 py-8 text-[15px] leading-8 text-slate-700',
|
||||||
|
'[&_a]:text-blue-600 [&_a]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-slate-300 [&_blockquote]:bg-slate-100/80 [&_blockquote]:px-4 [&_blockquote]:py-3 [&_blockquote]:italic',
|
||||||
|
'[&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
|
||||||
|
'[&_h1]:mt-2 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-semibold',
|
||||||
|
'[&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_hr]:my-8 [&_hr]:border-slate-200',
|
||||||
|
'[&_li]:my-1 [&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:my-4 [&_pre]:my-5 [&_pre]:overflow-x-auto [&_pre]:rounded-2xl [&_pre]:bg-slate-950 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-slate-100 [&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
||||||
|
'[&_table]:my-6 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-2xl [&_table]:border [&_table]:border-slate-200 [&_tbody_tr:nth-child(even)]:bg-slate-50/70 [&_td]:border [&_td]:border-slate-200 [&_td]:px-3 [&_td]:py-2 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-100 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left',
|
||||||
|
'[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6',
|
||||||
|
)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
335
admin/src/components/markdown-workbench.tsx
Normal file
335
admin/src/components/markdown-workbench.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
export type MarkdownWorkbenchPanel = 'edit' | 'preview' | 'diff'
|
||||||
|
export type MarkdownWorkbenchMode = 'workspace' | 'polish'
|
||||||
|
|
||||||
|
type MarkdownWorkbenchProps = {
|
||||||
|
value: string
|
||||||
|
originalValue: string
|
||||||
|
diffValue?: string
|
||||||
|
path: string
|
||||||
|
workspaceHeightClassName?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
mode: MarkdownWorkbenchMode
|
||||||
|
visiblePanels: MarkdownWorkbenchPanel[]
|
||||||
|
availablePanels?: MarkdownWorkbenchPanel[]
|
||||||
|
allowPolish?: boolean
|
||||||
|
preview: ReactNode
|
||||||
|
polishPanel?: ReactNode
|
||||||
|
originalLabel?: string
|
||||||
|
modifiedLabel?: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onModeChange: (next: MarkdownWorkbenchMode) => void
|
||||||
|
onVisiblePanelsChange: (next: MarkdownWorkbenchPanel[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorTheme = 'termi-vscode'
|
||||||
|
|
||||||
|
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
|
||||||
|
|
||||||
|
function formatPanelLabel(panel: MarkdownWorkbenchPanel) {
|
||||||
|
switch (panel) {
|
||||||
|
case 'preview':
|
||||||
|
return '预览'
|
||||||
|
case 'diff':
|
||||||
|
return '改动对比'
|
||||||
|
case 'edit':
|
||||||
|
default:
|
||||||
|
return '编辑'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVisiblePanels(
|
||||||
|
visiblePanels: MarkdownWorkbenchPanel[],
|
||||||
|
availablePanels: MarkdownWorkbenchPanel[],
|
||||||
|
) {
|
||||||
|
const orderedAvailablePanels = orderedWorkbenchPanels.filter((panel) =>
|
||||||
|
availablePanels.includes(panel),
|
||||||
|
)
|
||||||
|
const nextPanels = orderedAvailablePanels.filter((panel) => visiblePanels.includes(panel))
|
||||||
|
return nextPanels.length ? nextPanels : orderedAvailablePanels.slice(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configureMonaco: BeforeMount = (monaco) => {
|
||||||
|
monaco.editor.defineTheme(editorTheme, {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: 'comment', foreground: '6A9955' },
|
||||||
|
{ token: 'keyword', foreground: 'C586C0' },
|
||||||
|
{ token: 'string', foreground: 'CE9178' },
|
||||||
|
{ token: 'number', foreground: 'B5CEA8' },
|
||||||
|
{ token: 'delimiter', foreground: 'D4D4D4' },
|
||||||
|
{ token: 'type.identifier', foreground: '4EC9B0' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#1e1e1e',
|
||||||
|
'editor.foreground': '#d4d4d4',
|
||||||
|
'editor.lineHighlightBackground': '#2a2d2e',
|
||||||
|
'editor.lineHighlightBorder': '#00000000',
|
||||||
|
'editorCursor.foreground': '#aeafad',
|
||||||
|
'editor.selectionBackground': '#264f78',
|
||||||
|
'editor.inactiveSelectionBackground': '#3a3d41',
|
||||||
|
'editorWhitespace.foreground': '#3b3b3b',
|
||||||
|
'editorIndentGuide.background1': '#404040',
|
||||||
|
'editorIndentGuide.activeBackground1': '#707070',
|
||||||
|
'editorLineNumber.foreground': '#858585',
|
||||||
|
'editorLineNumber.activeForeground': '#c6c6c6',
|
||||||
|
'editorGutter.background': '#1e1e1e',
|
||||||
|
'editorOverviewRuler.border': '#00000000',
|
||||||
|
'diffEditor.insertedTextBackground': '#9ccc2c33',
|
||||||
|
'diffEditor.removedTextBackground': '#ff6b6b2d',
|
||||||
|
'diffEditor.insertedLineBackground': '#9ccc2c18',
|
||||||
|
'diffEditor.removedLineBackground': '#ff6b6b18',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sharedOptions = {
|
||||||
|
automaticLayout: true,
|
||||||
|
fontFamily:
|
||||||
|
'"JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, SFMono-Regular, monospace',
|
||||||
|
fontLigatures: true,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
padding: { top: 16, bottom: 16 },
|
||||||
|
renderWhitespace: 'selection' as const,
|
||||||
|
roundedSelection: false,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
smoothScrolling: true,
|
||||||
|
tabSize: 2,
|
||||||
|
wordWrap: 'on' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownWorkbench({
|
||||||
|
value,
|
||||||
|
originalValue,
|
||||||
|
diffValue,
|
||||||
|
path,
|
||||||
|
workspaceHeightClassName = 'h-[560px]',
|
||||||
|
readOnly = false,
|
||||||
|
mode,
|
||||||
|
visiblePanels,
|
||||||
|
availablePanels = ['edit', 'preview', 'diff'],
|
||||||
|
allowPolish,
|
||||||
|
preview,
|
||||||
|
polishPanel,
|
||||||
|
originalLabel = '基线版本',
|
||||||
|
modifiedLabel = '目标版本',
|
||||||
|
onChange,
|
||||||
|
onModeChange,
|
||||||
|
onVisiblePanelsChange,
|
||||||
|
}: MarkdownWorkbenchProps) {
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName
|
||||||
|
const diffContent = diffValue ?? value
|
||||||
|
const polishEnabled = allowPolish ?? Boolean(polishPanel)
|
||||||
|
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||||
|
const renderDiffSideBySide = workspacePanels.length < 3 || fullscreen
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
}
|
||||||
|
}, [fullscreen])
|
||||||
|
|
||||||
|
const togglePanel = (panel: MarkdownWorkbenchPanel) => {
|
||||||
|
const currentPanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||||
|
const nextPanels = currentPanels.includes(panel)
|
||||||
|
? currentPanels.filter((item) => item !== panel)
|
||||||
|
: orderedWorkbenchPanels.filter(
|
||||||
|
(item) => availablePanels.includes(item) && (currentPanels.includes(item) || item === panel),
|
||||||
|
)
|
||||||
|
|
||||||
|
onVisiblePanelsChange(nextPanels.length ? nextPanels : availablePanels.slice(0, 1))
|
||||||
|
|
||||||
|
if (mode !== 'workspace') {
|
||||||
|
onModeChange('workspace')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbench = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e] shadow-[0_24px_60px_rgba(15,23,42,0.28)]',
|
||||||
|
fullscreen && 'relative h-[100dvh] rounded-none border-0 shadow-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 border-b border-slate-800 bg-[#181818] px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{availablePanels.map((panel) => {
|
||||||
|
const active = mode === 'workspace' && workspacePanels.includes(panel)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={panel}
|
||||||
|
variant={active ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => togglePanel(panel)}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'bg-[#0e639c] text-white shadow-none hover:bg-[#1177bb]'
|
||||||
|
: 'border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatPanelLabel(panel)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{polishEnabled ? (
|
||||||
|
<Button
|
||||||
|
variant={mode === 'polish' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onModeChange(mode === 'polish' ? 'workspace' : 'polish')}
|
||||||
|
className={
|
||||||
|
mode === 'polish'
|
||||||
|
? 'bg-[#0e639c] text-white shadow-none hover:bg-[#1177bb]'
|
||||||
|
: 'border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="mr-1 h-4 w-4" />
|
||||||
|
AI 润色
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFullscreen((current) => !current)}
|
||||||
|
className="border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white"
|
||||||
|
>
|
||||||
|
{fullscreen ? (
|
||||||
|
<>
|
||||||
|
<Minimize2 className="mr-1 h-4 w-4" />
|
||||||
|
退出全屏
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Expand className="mr-1 h-4 w-4" />
|
||||||
|
全屏
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={editorHeight}>
|
||||||
|
{mode === 'polish' ? (
|
||||||
|
<div className="h-full bg-[#111111]">{polishPanel}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col bg-slate-900 xl:flex-row">
|
||||||
|
{workspacePanels.map((panel, index) => (
|
||||||
|
<section
|
||||||
|
key={panel}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-0 flex-1 flex-col bg-[#1b1b1b]',
|
||||||
|
index < workspacePanels.length - 1 &&
|
||||||
|
'border-b border-slate-800 xl:border-b-0 xl:border-r',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
<span>{formatPanelLabel(panel)}</span>
|
||||||
|
{panel === 'diff' ? (
|
||||||
|
<span>
|
||||||
|
{originalLabel} / {modifiedLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>{path}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{panel === 'edit' ? (
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<LazyEditor
|
||||||
|
height="100%"
|
||||||
|
language="markdown"
|
||||||
|
path={path}
|
||||||
|
value={value}
|
||||||
|
keepCurrentModel
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={configureMonaco}
|
||||||
|
options={{
|
||||||
|
...sharedOptions,
|
||||||
|
readOnly,
|
||||||
|
stickyScroll: { enabled: true },
|
||||||
|
}}
|
||||||
|
onChange={(next) => onChange(next ?? '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{panel === 'preview' ? (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-[#141414]">{preview}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{panel === 'diff' ? (
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<LazyDiffEditor
|
||||||
|
height="100%"
|
||||||
|
language="markdown"
|
||||||
|
original={originalValue}
|
||||||
|
modified={diffContent}
|
||||||
|
originalModelPath={`${path}#saved`}
|
||||||
|
modifiedModelPath={`${path}#draft`}
|
||||||
|
keepCurrentOriginalModel
|
||||||
|
keepCurrentModifiedModel
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={configureMonaco}
|
||||||
|
options={{
|
||||||
|
...sharedOptions,
|
||||||
|
originalEditable: false,
|
||||||
|
readOnly: true,
|
||||||
|
renderSideBySide: renderDiffSideBySide,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!fullscreen) {
|
||||||
|
return workbench
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return workbench
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[900] bg-slate-950/92 backdrop-blur-md" />
|
||||||
|
<div className="fixed inset-0 z-[1000]">{workbench}</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
33
admin/src/components/ui/badge.tsx
Normal file
33
admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
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',
|
||||||
|
warning: 'border-amber-500/20 bg-amber-500/12 text-amber-700',
|
||||||
|
danger: 'border-rose-500/20 bg-rose-500/12 text-rose-600',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)] hover:bg-primary/90',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
outline:
|
||||||
|
'border border-border bg-background/80 text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
danger:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-[0_12px_30px_rgb(220_38_38_/_0.18)] hover:bg-destructive/90',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-xl px-5',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
59
admin/src/components/ui/card.tsx
Normal file
59
admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-3xl border border-border/70 bg-card/85 text-card-foreground shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col gap-2 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold tracking-tight text-balance', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm leading-6 text-muted-foreground', className)} {...props} />
|
||||||
|
))
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('px-6 pb-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center px-6 pb-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||||
22
admin/src/components/ui/input.tsx
Normal file
22
admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
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 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
18
admin/src/components/ui/label.tsx
Normal file
18
admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
502
admin/src/components/ui/select.tsx
Normal file
502
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as ReactDOM from 'react-dom'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type NativeSelectProps = React.ComponentProps<'select'>
|
||||||
|
|
||||||
|
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>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
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 estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
|
||||||
|
const spaceAbove = rect.top - viewportPadding
|
||||||
|
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
|
||||||
|
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
|
||||||
|
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
|
||||||
|
const 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}
|
||||||
|
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-haspopup="listbox"
|
||||||
|
className={triggerClasses}
|
||||||
|
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'
|
||||||
|
|
||||||
|
export { Select }
|
||||||
28
admin/src/components/ui/separator.tsx
Normal file
28
admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
orientation?: 'horizontal' | 'vertical'
|
||||||
|
decorative?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden={decorative}
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border/80',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
9
admin/src/components/ui/skeleton.tsx
Normal file
9
admin/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { HTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
67
admin/src/components/ui/table.tsx
Normal file
67
admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border/70', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border/60 transition-colors hover:bg-accent/40 data-[state=selected]:bg-accent/60',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-11 px-4 text-left align-middle text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn('p-4 align-middle', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow }
|
||||||
21
admin/src/components/ui/textarea.tsx
Normal file
21
admin/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[132px] w-full rounded-2xl border border-input bg-background/80 px-3 py-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
138
admin/src/index.css
Normal file
138
admin/src/index.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(0.98 0.008 240);
|
||||||
|
--foreground: oklch(0.18 0.02 255);
|
||||||
|
--card: oklch(1 0 0 / 0.82);
|
||||||
|
--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);
|
||||||
|
--secondary: oklch(0.94 0.02 220);
|
||||||
|
--secondary-foreground: oklch(0.28 0.03 250);
|
||||||
|
--muted: oklch(0.95 0.01 250);
|
||||||
|
--muted-foreground: oklch(0.48 0.02 250);
|
||||||
|
--accent: oklch(0.88 0.04 205);
|
||||||
|
--accent-foreground: oklch(0.24 0.03 255);
|
||||||
|
--destructive: oklch(0.62 0.22 28);
|
||||||
|
--destructive-foreground: oklch(0.98 0.01 28);
|
||||||
|
--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);
|
||||||
|
--warning: oklch(0.81 0.16 78);
|
||||||
|
--radius: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.16 0.02 258);
|
||||||
|
--foreground: oklch(0.95 0.01 255);
|
||||||
|
--card: oklch(0.19 0.02 258 / 0.9);
|
||||||
|
--card-foreground: oklch(0.95 0.01 255);
|
||||||
|
--popover: oklch(0.2 0.02 258);
|
||||||
|
--popover-foreground: oklch(0.95 0.01 255);
|
||||||
|
--primary: oklch(0.71 0.15 246);
|
||||||
|
--primary-foreground: oklch(0.2 0.02 258);
|
||||||
|
--secondary: oklch(0.25 0.02 258);
|
||||||
|
--secondary-foreground: oklch(0.94 0.01 255);
|
||||||
|
--muted: oklch(0.24 0.02 258);
|
||||||
|
--muted-foreground: oklch(0.72 0.02 255);
|
||||||
|
--accent: oklch(0.31 0.04 215);
|
||||||
|
--accent-foreground: oklch(0.94 0.01 255);
|
||||||
|
--destructive: oklch(0.69 0.19 26);
|
||||||
|
--destructive-foreground: oklch(0.96 0.01 26);
|
||||||
|
--border: oklch(0.3 0.02 258);
|
||||||
|
--input: oklch(0.29 0.02 258);
|
||||||
|
--ring: oklch(0.71 0.15 246);
|
||||||
|
--success: oklch(0.75 0.15 160);
|
||||||
|
--warning: oklch(0.84 0.15 84);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--radius-sm: calc(var(--radius) - 0.35rem);
|
||||||
|
--radius-md: calc(var(--radius) - 0.15rem);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 0.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at top left, rgb(77 132 255 / 0.12), transparent 24rem),
|
||||||
|
radial-gradient(circle at top right, rgb(16 185 129 / 0.08), transparent 22rem),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 0.66), transparent 26rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(119 140 173 / 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(119 140 173 / 0.06) 1px, transparent 1px);
|
||||||
|
background-size: 100% 1.35rem, 1.35rem 100%;
|
||||||
|
mask-image: linear-gradient(180deg, rgb(0 0 0 / 0.75), transparent 86%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
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;
|
||||||
|
}
|
||||||
207
admin/src/lib/admin-format.ts
Normal file
207
admin/src/lib/admin-format.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
export function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '暂无'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPostType(value: string | null | undefined) {
|
||||||
|
switch (value) {
|
||||||
|
case 'article':
|
||||||
|
return '文章'
|
||||||
|
case 'note':
|
||||||
|
return '笔记'
|
||||||
|
case 'page':
|
||||||
|
return '页面'
|
||||||
|
case 'snippet':
|
||||||
|
return '片段'
|
||||||
|
default:
|
||||||
|
return value || '文章'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCommentScope(value: string | null | undefined) {
|
||||||
|
switch (value) {
|
||||||
|
case 'paragraph':
|
||||||
|
return '段落'
|
||||||
|
case 'article':
|
||||||
|
return '全文'
|
||||||
|
default:
|
||||||
|
return value || '全文'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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':
|
||||||
|
return '已通过'
|
||||||
|
case 'rejected':
|
||||||
|
return '已拒绝'
|
||||||
|
case 'pending':
|
||||||
|
return '待审核'
|
||||||
|
default:
|
||||||
|
return value || '待审核'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReviewType(value: string | null | undefined) {
|
||||||
|
switch (value) {
|
||||||
|
case 'book':
|
||||||
|
return '图书'
|
||||||
|
case 'movie':
|
||||||
|
return '电影'
|
||||||
|
case 'game':
|
||||||
|
return '游戏'
|
||||||
|
case 'anime':
|
||||||
|
return '动画'
|
||||||
|
case 'music':
|
||||||
|
return '音乐'
|
||||||
|
default:
|
||||||
|
return value || '未分类'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReviewStatus(value: string | null | undefined) {
|
||||||
|
switch (value) {
|
||||||
|
case 'published':
|
||||||
|
return '已发布'
|
||||||
|
case 'draft':
|
||||||
|
return '草稿'
|
||||||
|
case 'archived':
|
||||||
|
return '已归档'
|
||||||
|
case 'completed':
|
||||||
|
return '已完成'
|
||||||
|
case 'in-progress':
|
||||||
|
return '进行中'
|
||||||
|
default:
|
||||||
|
return value || '未知状态'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyToNull(value: string) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linesToList(value: string) {
|
||||||
|
return value
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvToList(value: string) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postTagsToList(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewTagsToList(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return csvToList(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
587
admin/src/lib/api.ts
Normal file
587
admin/src/lib/api.ts
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
import type {
|
||||||
|
AdminAnalyticsResponse,
|
||||||
|
AdminAiImageProviderTestResponse,
|
||||||
|
AdminAiReindexResponse,
|
||||||
|
AdminAiProviderTestResponse,
|
||||||
|
AdminImageUploadResponse,
|
||||||
|
AdminMediaBatchDeleteResponse,
|
||||||
|
AdminMediaDeleteResponse,
|
||||||
|
AdminMediaListResponse,
|
||||||
|
AdminMediaReplaceResponse,
|
||||||
|
AdminMediaUploadResponse,
|
||||||
|
AdminPostCoverImageRequest,
|
||||||
|
AdminPostCoverImageResponse,
|
||||||
|
AdminDashboardResponse,
|
||||||
|
AdminPostMetadataResponse,
|
||||||
|
AdminPostPolishResponse,
|
||||||
|
AdminReviewPolishRequest,
|
||||||
|
AdminReviewPolishResponse,
|
||||||
|
AdminR2ConnectivityResponse,
|
||||||
|
AdminSessionResponse,
|
||||||
|
AdminSiteSettingsResponse,
|
||||||
|
AuditLogRecord,
|
||||||
|
CommentListQuery,
|
||||||
|
CommentBlacklistRecord,
|
||||||
|
CommentPersonaAnalysisLogRecord,
|
||||||
|
CommentPersonaAnalysisResponse,
|
||||||
|
CommentRecord,
|
||||||
|
CreatePostPayload,
|
||||||
|
CreateReviewPayload,
|
||||||
|
FriendLinkListQuery,
|
||||||
|
FriendLinkPayload,
|
||||||
|
FriendLinkRecord,
|
||||||
|
MarkdownDeleteResponse,
|
||||||
|
MarkdownDocumentResponse,
|
||||||
|
MarkdownImportResponse,
|
||||||
|
NotificationDeliveryRecord,
|
||||||
|
PostListQuery,
|
||||||
|
PostRevisionDetail,
|
||||||
|
PostRevisionRecord,
|
||||||
|
PostRecord,
|
||||||
|
ReviewRecord,
|
||||||
|
RestoreRevisionResponse,
|
||||||
|
SiteSettingsPayload,
|
||||||
|
SubscriptionDigestResponse,
|
||||||
|
SubscriptionListResponse,
|
||||||
|
SubscriptionPayload,
|
||||||
|
SubscriptionRecord,
|
||||||
|
SubscriptionUpdatePayload,
|
||||||
|
UpdateCommentPayload,
|
||||||
|
UpdatePostPayload,
|
||||||
|
UpdateReviewPayload,
|
||||||
|
} from '@/lib/types'
|
||||||
|
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
|
||||||
|
|
||||||
|
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
|
||||||
|
const DEV_API_BASE = 'http://localhost:5150'
|
||||||
|
const PROD_DEFAULT_API_PORT = '5150'
|
||||||
|
|
||||||
|
function getApiBase() {
|
||||||
|
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
|
||||||
|
if (runtimeApiBase) {
|
||||||
|
return runtimeApiBase
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envApiBase) {
|
||||||
|
return envApiBase
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return DEV_API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return DEV_API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
const { protocol, hostname } = window.location
|
||||||
|
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = getApiBase()
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response) {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return `请求失败,状态码 ${response.status}。`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { description?: string; error?: string; message?: string }
|
||||||
|
return parsed.description || parsed.error || parsed.message || text
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQueryParams(path: string, params?: Record<string, unknown>) {
|
||||||
|
if (!params) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
searchParams.set(key, String(value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParams.set(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryString = searchParams.toString()
|
||||||
|
return queryString ? `${path}?${queryString}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const headers = new Headers(init?.headers)
|
||||||
|
|
||||||
|
if (init?.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(await readErrorMessage(response), response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.text()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
sessionStatus: () => request<AdminSessionResponse>('/api/admin/session'),
|
||||||
|
login: (payload: { username: string; password: string }) =>
|
||||||
|
request<AdminSessionResponse>('/api/admin/session/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
logout: () =>
|
||||||
|
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 }>(`/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 }),
|
||||||
|
}),
|
||||||
|
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
||||||
|
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
|
||||||
|
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||||
|
updateSiteSettings: (payload: SiteSettingsPayload) =>
|
||||||
|
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
reindexAi: () =>
|
||||||
|
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
testAiProvider: (provider: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
api_base: string | null
|
||||||
|
api_key: string | null
|
||||||
|
chat_model: string | null
|
||||||
|
}) =>
|
||||||
|
request<AdminAiProviderTestResponse>('/api/admin/ai/test-provider', {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
generatePostMetadata: (markdown: string) =>
|
||||||
|
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ markdown }),
|
||||||
|
}),
|
||||||
|
polishPostMarkdown: (markdown: string) =>
|
||||||
|
request<AdminPostPolishResponse>('/api/admin/ai/polish-post', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ markdown }),
|
||||||
|
}),
|
||||||
|
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', {
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
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',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: payload.title,
|
||||||
|
slug: payload.slug,
|
||||||
|
description: payload.description,
|
||||||
|
content: payload.content,
|
||||||
|
category: payload.category,
|
||||||
|
tags: payload.tags,
|
||||||
|
post_type: payload.postType,
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
updatePost: (id: number, payload: UpdatePostPayload) =>
|
||||||
|
request<PostRecord>(`/api/posts/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: payload.title,
|
||||||
|
slug: payload.slug,
|
||||||
|
description: payload.description,
|
||||||
|
content: payload.content,
|
||||||
|
category: payload.category,
|
||||||
|
tags: payload.tags,
|
||||||
|
post_type: payload.postType,
|
||||||
|
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) =>
|
||||||
|
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`),
|
||||||
|
importPosts: async (files: File[]) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append('files', file, file.webkitRelativePath || file.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return request<MarkdownImportResponse>('/api/posts/markdown/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updatePostMarkdown: (slug: string, markdown: string) =>
|
||||||
|
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ markdown }),
|
||||||
|
}),
|
||||||
|
deletePost: (slug: string) =>
|
||||||
|
request<MarkdownDeleteResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
listComments: (query?: CommentListQuery) =>
|
||||||
|
request<CommentRecord[]>(
|
||||||
|
appendQueryParams('/api/comments', {
|
||||||
|
post_id: query?.postId,
|
||||||
|
post_slug: query?.postSlug,
|
||||||
|
scope: query?.scope,
|
||||||
|
paragraph_key: query?.paragraphKey,
|
||||||
|
approved: query?.approved,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
updateComment: (id: number, payload: UpdateCommentPayload) =>
|
||||||
|
request<CommentRecord>(`/api/comments/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteComment: (id: number) =>
|
||||||
|
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', {
|
||||||
|
status: query?.status,
|
||||||
|
category: query?.category,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
createFriendLink: (payload: FriendLinkPayload) =>
|
||||||
|
request<FriendLinkRecord>('/api/friend_links', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
siteName: payload.siteName,
|
||||||
|
siteUrl: payload.siteUrl,
|
||||||
|
avatarUrl: payload.avatarUrl,
|
||||||
|
description: payload.description,
|
||||||
|
category: payload.category,
|
||||||
|
status: payload.status,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
updateFriendLink: (id: number, payload: FriendLinkPayload) =>
|
||||||
|
request<FriendLinkRecord>(`/api/friend_links/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
site_name: payload.siteName,
|
||||||
|
site_url: payload.siteUrl,
|
||||||
|
avatar_url: payload.avatarUrl,
|
||||||
|
description: payload.description,
|
||||||
|
category: payload.category,
|
||||||
|
status: payload.status,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
deleteFriendLink: (id: number) =>
|
||||||
|
request<void>(`/api/friend_links/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
listReviews: () => request<ReviewRecord[]>('/api/reviews'),
|
||||||
|
createReview: (payload: CreateReviewPayload) =>
|
||||||
|
request<ReviewRecord>('/api/reviews', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
updateReview: (id: number, payload: UpdateReviewPayload) =>
|
||||||
|
request<ReviewRecord>(`/api/reviews/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteReview: (id: number) =>
|
||||||
|
request<void>(`/api/reviews/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
}
|
||||||
28
admin/src/lib/frontend-url.ts
Normal file
28
admin/src/lib/frontend-url.ts
Normal 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}`
|
||||||
|
}
|
||||||
279
admin/src/lib/image-compress.ts
Normal file
279
admin/src/lib/image-compress.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
export interface CompressionPreview {
|
||||||
|
originalSize: number
|
||||||
|
compressedSize: number
|
||||||
|
savedBytes: number
|
||||||
|
savedRatio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompressionResult {
|
||||||
|
file: File
|
||||||
|
usedCompressed: boolean
|
||||||
|
preview: CompressionPreview | null
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
let processed: File
|
||||||
|
try {
|
||||||
|
processed = await processImage(file, {
|
||||||
|
quality,
|
||||||
|
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
|
||||||
|
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
|
||||||
|
preferredFormats:
|
||||||
|
options?.preferredFormats && options.preferredFormats.length
|
||||||
|
? options.preferredFormats
|
||||||
|
: file.type === 'image/png'
|
||||||
|
? ['image/png', 'image/webp', 'image/jpeg']
|
||||||
|
: ['image/webp', 'image/avif', 'image/jpeg'],
|
||||||
|
coverWidth: options?.coverWidth,
|
||||||
|
coverHeight: options?.coverHeight,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return { file, usedCompressed: false, preview: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedBytes = file.size - processed.size
|
||||||
|
const savedRatio = file.size > 0 ? savedBytes / file.size : 0
|
||||||
|
const preview: CompressionPreview = {
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: processed.size,
|
||||||
|
savedBytes,
|
||||||
|
savedRatio,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaText =
|
||||||
|
savedBytes >= 0
|
||||||
|
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)`
|
||||||
|
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)`
|
||||||
|
|
||||||
|
const intro = forceProcessed
|
||||||
|
? `${contextLabel}: 已生成规范化版本。`
|
||||||
|
: `${contextLabel}: 检测到可压缩空间。`
|
||||||
|
|
||||||
|
const useProcessed = window.confirm(
|
||||||
|
[
|
||||||
|
intro,
|
||||||
|
`原始: ${formatBytes(file.size)}`,
|
||||||
|
`处理后: ${formatBytes(processed.size)}`,
|
||||||
|
deltaText,
|
||||||
|
'',
|
||||||
|
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: useProcessed ? processed : file,
|
||||||
|
usedCompressed: useProcessed,
|
||||||
|
preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeCompressImageWithPrompt(
|
||||||
|
file: File,
|
||||||
|
options?: {
|
||||||
|
quality?: number
|
||||||
|
ask?: boolean
|
||||||
|
minSavingsRatio?: number
|
||||||
|
contextLabel?: string
|
||||||
|
},
|
||||||
|
): Promise<CompressionResult> {
|
||||||
|
return maybeProcessImageWithPrompt(file, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeCoverImageWithPrompt(
|
||||||
|
file: File,
|
||||||
|
options?: {
|
||||||
|
quality?: number
|
||||||
|
ask?: boolean
|
||||||
|
contextLabel?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
},
|
||||||
|
): Promise<CompressionResult> {
|
||||||
|
return maybeProcessImageWithPrompt(file, {
|
||||||
|
quality: options?.quality ?? 0.82,
|
||||||
|
ask: options?.ask ?? true,
|
||||||
|
contextLabel: options?.contextLabel ?? '封面图规范化',
|
||||||
|
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 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)}%`
|
||||||
|
}
|
||||||
32
admin/src/lib/markdown-diff.ts
Normal file
32
admin/src/lib/markdown-diff.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export function normalizeMarkdown(value: string) {
|
||||||
|
return value.replace(/\r\n/g, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countLineDiff(left: string, right: string) {
|
||||||
|
const leftLines = normalizeMarkdown(left).split('\n')
|
||||||
|
const rightLines = normalizeMarkdown(right).split('\n')
|
||||||
|
const previous = new Array(rightLines.length + 1).fill(0)
|
||||||
|
|
||||||
|
for (let leftIndex = 1; leftIndex <= leftLines.length; leftIndex += 1) {
|
||||||
|
const current = new Array(rightLines.length + 1).fill(0)
|
||||||
|
|
||||||
|
for (let rightIndex = 1; rightIndex <= rightLines.length; rightIndex += 1) {
|
||||||
|
if (leftLines[leftIndex - 1] === rightLines[rightIndex - 1]) {
|
||||||
|
current[rightIndex] = previous[rightIndex - 1] + 1
|
||||||
|
} else {
|
||||||
|
current[rightIndex] = Math.max(previous[rightIndex], current[rightIndex - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let rightIndex = 0; rightIndex <= rightLines.length; rightIndex += 1) {
|
||||||
|
previous[rightIndex] = current[rightIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = previous[rightLines.length]
|
||||||
|
|
||||||
|
return {
|
||||||
|
additions: Math.max(rightLines.length - common, 0),
|
||||||
|
deletions: Math.max(leftLines.length - common, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
328
admin/src/lib/markdown-document.ts
Normal file
328
admin/src/lib/markdown-document.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { normalizeMarkdown } from '@/lib/markdown-diff'
|
||||||
|
|
||||||
|
export type ParsedMarkdownMeta = {
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
postType: string
|
||||||
|
image: string
|
||||||
|
images: string[]
|
||||||
|
pinned: boolean
|
||||||
|
status: string
|
||||||
|
visibility: string
|
||||||
|
publishAt: string
|
||||||
|
unpublishAt: string
|
||||||
|
canonicalUrl: string
|
||||||
|
noindex: boolean
|
||||||
|
ogImage: string
|
||||||
|
redirectFrom: string[]
|
||||||
|
redirectTo: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedMarkdownDocument = {
|
||||||
|
meta: ParsedMarkdownMeta
|
||||||
|
body: string
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMeta: ParsedMarkdownMeta = {
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
postType: 'article',
|
||||||
|
image: '',
|
||||||
|
images: [],
|
||||||
|
pinned: false,
|
||||||
|
status: 'published',
|
||||||
|
visibility: 'public',
|
||||||
|
publishAt: '',
|
||||||
|
unpublishAt: '',
|
||||||
|
canonicalUrl: '',
|
||||||
|
noindex: false,
|
||||||
|
ogImage: '',
|
||||||
|
redirectFrom: [],
|
||||||
|
redirectTo: '',
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(value: string) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmed.startsWith('"') ||
|
||||||
|
trimmed.startsWith("'") ||
|
||||||
|
trimmed.startsWith('[') ||
|
||||||
|
trimmed.startsWith('{')
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
return trimmed.replace(/^['"]|['"]$/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === 'true') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === 'false') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringList(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument {
|
||||||
|
const normalized = normalizeMarkdown(markdown)
|
||||||
|
const meta: ParsedMarkdownMeta = { ...defaultMeta }
|
||||||
|
|
||||||
|
if (!normalized.startsWith('---\n')) {
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
body: normalized.trimStart(),
|
||||||
|
markdown: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endIndex = normalized.indexOf('\n---\n', 4)
|
||||||
|
if (endIndex === -1) {
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
body: normalized.trimStart(),
|
||||||
|
markdown: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatter = normalized.slice(4, endIndex)
|
||||||
|
const body = normalized.slice(endIndex + 5).trimStart()
|
||||||
|
let currentListKey: 'tags' | 'images' | 'categories' | 'redirect_from' | null = null
|
||||||
|
const categories: string[] = []
|
||||||
|
|
||||||
|
frontmatter.split('\n').forEach((line) => {
|
||||||
|
const listItemMatch = line.match(/^\s*-\s*(.+)\s*$/)
|
||||||
|
if (listItemMatch && currentListKey) {
|
||||||
|
const parsed = parseScalar(listItemMatch[1])
|
||||||
|
const nextValue = typeof parsed === 'string' ? parsed.trim() : String(parsed).trim()
|
||||||
|
if (!nextValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentListKey === 'tags') {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentListKey = null
|
||||||
|
|
||||||
|
const keyMatch = line.match(/^([A-Za-z_]+):\s*(.*)$/)
|
||||||
|
if (!keyMatch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, rawKey, rawValue] = keyMatch
|
||||||
|
const key = rawKey.trim()
|
||||||
|
const value = parseScalar(rawValue)
|
||||||
|
|
||||||
|
if (key === 'tags') {
|
||||||
|
const tags = toStringList(value)
|
||||||
|
if (tags.length) {
|
||||||
|
meta.tags = tags
|
||||||
|
} else if (!String(rawValue).trim()) {
|
||||||
|
currentListKey = 'tags'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'images') {
|
||||||
|
const images = toStringList(value)
|
||||||
|
if (images.length) {
|
||||||
|
meta.images = images
|
||||||
|
} else if (!String(rawValue).trim()) {
|
||||||
|
currentListKey = 'images'
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
categories.push(...parsedCategories)
|
||||||
|
} else if (!String(rawValue).trim()) {
|
||||||
|
currentListKey = 'categories'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'title':
|
||||||
|
meta.title = String(value).trim()
|
||||||
|
break
|
||||||
|
case 'slug':
|
||||||
|
meta.slug = String(value).trim()
|
||||||
|
break
|
||||||
|
case 'description':
|
||||||
|
meta.description = String(value).trim()
|
||||||
|
break
|
||||||
|
case 'post_type':
|
||||||
|
meta.postType = String(value).trim() || 'article'
|
||||||
|
break
|
||||||
|
case 'image':
|
||||||
|
meta.image = String(value).trim()
|
||||||
|
break
|
||||||
|
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.status = value === false ? 'draft' : 'published'
|
||||||
|
break
|
||||||
|
case 'draft':
|
||||||
|
if (value === true) {
|
||||||
|
meta.status = 'draft'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
meta.category = categories[0] ?? meta.category
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
body,
|
||||||
|
markdown: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
`title: ${JSON.stringify(meta.title.trim() || meta.slug || 'untitled-post')}`,
|
||||||
|
`slug: ${meta.slug.trim() || 'untitled-post'}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (meta.description.trim()) {
|
||||||
|
lines.push(`description: ${JSON.stringify(meta.description.trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.category.trim()) {
|
||||||
|
lines.push(`category: ${JSON.stringify(meta.category.trim())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`)
|
||||||
|
lines.push(`pinned: ${meta.pinned ? '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())}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.images.length) {
|
||||||
|
lines.push('images:')
|
||||||
|
meta.images.forEach((image) => {
|
||||||
|
lines.push(` - ${JSON.stringify(image)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.tags.length) {
|
||||||
|
lines.push('tags:')
|
||||||
|
meta.tags.forEach((tag) => {
|
||||||
|
lines.push(` - ${JSON.stringify(tag)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
149
admin/src/lib/markdown-merge.ts
Normal file
149
admin/src/lib/markdown-merge.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { normalizeMarkdown } from '@/lib/markdown-diff'
|
||||||
|
|
||||||
|
type DiffOperation =
|
||||||
|
| { type: 'equal'; line: string }
|
||||||
|
| { type: 'delete'; line: string }
|
||||||
|
| { type: 'insert'; line: string }
|
||||||
|
|
||||||
|
export type DiffHunk = {
|
||||||
|
id: string
|
||||||
|
originalStart: number
|
||||||
|
originalEnd: number
|
||||||
|
modifiedStart: number
|
||||||
|
modifiedEnd: number
|
||||||
|
removedLines: string[]
|
||||||
|
addedLines: string[]
|
||||||
|
preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffOperations(originalLines: string[], modifiedLines: string[]) {
|
||||||
|
const rows = originalLines.length
|
||||||
|
const cols = modifiedLines.length
|
||||||
|
const dp = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0))
|
||||||
|
|
||||||
|
for (let row = 1; row <= rows; row += 1) {
|
||||||
|
for (let col = 1; col <= cols; col += 1) {
|
||||||
|
if (originalLines[row - 1] === modifiedLines[col - 1]) {
|
||||||
|
dp[row][col] = dp[row - 1][col - 1] + 1
|
||||||
|
} else {
|
||||||
|
dp[row][col] = Math.max(dp[row - 1][col], dp[row][col - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations: DiffOperation[] = []
|
||||||
|
let row = rows
|
||||||
|
let col = cols
|
||||||
|
|
||||||
|
while (row > 0 || col > 0) {
|
||||||
|
if (row > 0 && col > 0 && originalLines[row - 1] === modifiedLines[col - 1]) {
|
||||||
|
operations.push({ type: 'equal', line: originalLines[row - 1] })
|
||||||
|
row -= 1
|
||||||
|
col -= 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col > 0 && (row === 0 || dp[row][col - 1] >= dp[row - 1][col])) {
|
||||||
|
operations.push({ type: 'insert', line: modifiedLines[col - 1] })
|
||||||
|
col -= 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
operations.push({ type: 'delete', line: originalLines[row - 1] })
|
||||||
|
row -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return operations.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDiffHunks(original: string, modified: string): DiffHunk[] {
|
||||||
|
const originalLines = normalizeMarkdown(original).split('\n')
|
||||||
|
const modifiedLines = normalizeMarkdown(modified).split('\n')
|
||||||
|
const operations = diffOperations(originalLines, modifiedLines)
|
||||||
|
const hunks: DiffHunk[] = []
|
||||||
|
let originalLine = 1
|
||||||
|
let modifiedLine = 1
|
||||||
|
let current:
|
||||||
|
| (Omit<DiffHunk, 'id' | 'originalEnd' | 'modifiedEnd' | 'preview'> & {
|
||||||
|
idSeed: number
|
||||||
|
})
|
||||||
|
| null = null
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewSource = current.addedLines.join(' ').trim() || current.removedLines.join(' ').trim()
|
||||||
|
hunks.push({
|
||||||
|
id: `hunk-${current.idSeed}`,
|
||||||
|
originalStart: current.originalStart,
|
||||||
|
originalEnd: originalLine - 1,
|
||||||
|
modifiedStart: current.modifiedStart,
|
||||||
|
modifiedEnd: modifiedLine - 1,
|
||||||
|
removedLines: current.removedLines,
|
||||||
|
addedLines: current.addedLines,
|
||||||
|
preview: previewSource.slice(0, 120) || '空白改动',
|
||||||
|
})
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
operations.forEach((operation) => {
|
||||||
|
if (operation.type === 'equal') {
|
||||||
|
flush()
|
||||||
|
originalLine += 1
|
||||||
|
modifiedLine += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
current = {
|
||||||
|
idSeed: hunks.length + 1,
|
||||||
|
originalStart: originalLine,
|
||||||
|
modifiedStart: modifiedLine,
|
||||||
|
removedLines: [],
|
||||||
|
addedLines: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.type === 'delete') {
|
||||||
|
current.removedLines.push(operation.line)
|
||||||
|
originalLine += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current.addedLines.push(operation.line)
|
||||||
|
modifiedLine += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
|
return hunks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySelectedDiffHunks(
|
||||||
|
original: string,
|
||||||
|
hunks: DiffHunk[],
|
||||||
|
selectedIds: Set<string>,
|
||||||
|
) {
|
||||||
|
const originalLines = normalizeMarkdown(original).split('\n')
|
||||||
|
const resultLines: string[] = []
|
||||||
|
let cursor = 1
|
||||||
|
|
||||||
|
hunks.forEach((hunk) => {
|
||||||
|
const unchangedEnd = Math.max(hunk.originalStart - 1, cursor - 1)
|
||||||
|
resultLines.push(...originalLines.slice(cursor - 1, unchangedEnd))
|
||||||
|
|
||||||
|
if (selectedIds.has(hunk.id)) {
|
||||||
|
resultLines.push(...hunk.addedLines)
|
||||||
|
} else if (hunk.originalEnd >= hunk.originalStart) {
|
||||||
|
resultLines.push(...originalLines.slice(hunk.originalStart - 1, hunk.originalEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = Math.max(hunk.originalEnd + 1, hunk.originalStart)
|
||||||
|
})
|
||||||
|
|
||||||
|
resultLines.push(...originalLines.slice(cursor - 1))
|
||||||
|
|
||||||
|
return resultLines.join('\n')
|
||||||
|
}
|
||||||
82
admin/src/lib/post-draft-window.ts
Normal file
82
admin/src/lib/post-draft-window.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export type DraftWindowSnapshot = {
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
path: string
|
||||||
|
markdown: string
|
||||||
|
savedMarkdown: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'termi-admin-post-draft:'
|
||||||
|
const POLISH_RESULT_PREFIX = 'termi-admin-post-polish-result:'
|
||||||
|
|
||||||
|
export type PolishWindowResult = {
|
||||||
|
draftKey: string
|
||||||
|
markdown: string
|
||||||
|
target: 'editor' | 'create'
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDraftWindowSnapshot(snapshot: Omit<DraftWindowSnapshot, 'createdAt'>) {
|
||||||
|
const key = `${STORAGE_PREFIX}${snapshot.slug}:${Date.now()}`
|
||||||
|
const payload: DraftWindowSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(payload))
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDraftWindowSnapshot(key: string | null) {
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as DraftWindowSnapshot
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePolishWindowResult(
|
||||||
|
draftKey: string,
|
||||||
|
markdown: string,
|
||||||
|
target: 'editor' | 'create',
|
||||||
|
) {
|
||||||
|
const payload: PolishWindowResult = {
|
||||||
|
draftKey,
|
||||||
|
markdown,
|
||||||
|
target,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(`${POLISH_RESULT_PREFIX}${draftKey}`, JSON.stringify(payload))
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePolishWindowResult(key: string | null) {
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||||
|
const raw = window.localStorage.getItem(storageKey)
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.removeItem(storageKey)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as PolishWindowResult
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
22
admin/src/lib/runtime-config.ts
Normal file
22
admin/src/lib/runtime-config.ts
Normal 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])
|
||||||
|
}
|
||||||
768
admin/src/lib/types.ts
Normal file
768
admin/src/lib/types.ts
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
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 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
|
||||||
|
total_links: number
|
||||||
|
pending_links: number
|
||||||
|
ai_chunks: number
|
||||||
|
ai_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPostItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
category: string
|
||||||
|
post_type: string
|
||||||
|
pinned: boolean
|
||||||
|
status: string
|
||||||
|
visibility: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCommentItem {
|
||||||
|
id: number
|
||||||
|
author: string
|
||||||
|
post_slug: string
|
||||||
|
scope: string
|
||||||
|
excerpt: string
|
||||||
|
approved: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardFriendLinkItem {
|
||||||
|
id: number
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
category: string
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardReviewItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
review_type: string
|
||||||
|
rating: number
|
||||||
|
status: string
|
||||||
|
review_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSiteSummary {
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
ai_enabled: boolean
|
||||||
|
ai_chunks: number
|
||||||
|
ai_last_indexed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardResponse {
|
||||||
|
stats: DashboardStats
|
||||||
|
site: DashboardSiteSummary
|
||||||
|
recent_posts: DashboardPostItem[]
|
||||||
|
pending_comments: DashboardCommentItem[]
|
||||||
|
pending_friend_links: DashboardFriendLinkItem[]
|
||||||
|
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[]
|
||||||
|
popular_posts: AnalyticsPopularPost[]
|
||||||
|
daily_activity: AnalyticsDailyBucket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSiteSettingsResponse {
|
||||||
|
id: number
|
||||||
|
site_name: string | null
|
||||||
|
site_short_name: string | null
|
||||||
|
site_url: string | null
|
||||||
|
site_title: string | null
|
||||||
|
site_description: string | null
|
||||||
|
hero_title: string | null
|
||||||
|
hero_subtitle: string | null
|
||||||
|
owner_name: string | null
|
||||||
|
owner_title: string | null
|
||||||
|
owner_bio: string | null
|
||||||
|
owner_avatar_url: string | null
|
||||||
|
social_github: string | null
|
||||||
|
social_twitter: string | null
|
||||||
|
social_email: string | null
|
||||||
|
location: string | null
|
||||||
|
tech_stack: string[]
|
||||||
|
music_playlist: MusicTrack[]
|
||||||
|
ai_enabled: boolean
|
||||||
|
paragraph_comments_enabled: boolean
|
||||||
|
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
|
||||||
|
ai_system_prompt: string | null
|
||||||
|
ai_top_k: number | null
|
||||||
|
ai_chunk_size: number | null
|
||||||
|
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_default_og_image: string | null
|
||||||
|
seo_default_twitter_handle: string | null
|
||||||
|
notification_webhook_url: string | null
|
||||||
|
notification_comment_enabled: boolean
|
||||||
|
notification_friend_link_enabled: boolean
|
||||||
|
search_synonyms: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiProviderConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
api_base: string | null
|
||||||
|
api_key: string | null
|
||||||
|
chat_model: string | null
|
||||||
|
image_model: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSettingsPayload {
|
||||||
|
siteName?: string | null
|
||||||
|
siteShortName?: string | null
|
||||||
|
siteUrl?: string | null
|
||||||
|
siteTitle?: string | null
|
||||||
|
siteDescription?: string | null
|
||||||
|
heroTitle?: string | null
|
||||||
|
heroSubtitle?: string | null
|
||||||
|
ownerName?: string | null
|
||||||
|
ownerTitle?: string | null
|
||||||
|
ownerBio?: string | null
|
||||||
|
ownerAvatarUrl?: string | null
|
||||||
|
socialGithub?: string | null
|
||||||
|
socialTwitter?: string | null
|
||||||
|
socialEmail?: string | null
|
||||||
|
location?: string | null
|
||||||
|
techStack?: string[]
|
||||||
|
musicPlaylist?: MusicTrack[]
|
||||||
|
aiEnabled?: boolean
|
||||||
|
paragraphCommentsEnabled?: boolean
|
||||||
|
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
|
||||||
|
seoDefaultOgImage?: string | null
|
||||||
|
seoDefaultTwitterHandle?: string | null
|
||||||
|
notificationWebhookUrl?: string | null
|
||||||
|
notificationCommentEnabled?: boolean
|
||||||
|
notificationFriendLinkEnabled?: boolean
|
||||||
|
searchSynonyms?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAiReindexResponse {
|
||||||
|
indexed_chunks: number
|
||||||
|
last_indexed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAiProviderTestResponse {
|
||||||
|
provider: string
|
||||||
|
endpoint: string
|
||||||
|
chat_model: string
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
album?: string | null
|
||||||
|
url: string
|
||||||
|
cover_image_url?: string | null
|
||||||
|
accent_color?: string | null
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPostMetadataResponse {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPostPolishResponse {
|
||||||
|
polished_markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
id: number
|
||||||
|
title: string | null
|
||||||
|
slug: string
|
||||||
|
description: string | null
|
||||||
|
content: string | null
|
||||||
|
category: string | null
|
||||||
|
tags: unknown
|
||||||
|
post_type: string | null
|
||||||
|
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 {
|
||||||
|
slug?: string
|
||||||
|
category?: string
|
||||||
|
tag?: string
|
||||||
|
search?: string
|
||||||
|
postType?: string
|
||||||
|
pinned?: boolean
|
||||||
|
status?: string
|
||||||
|
visibility?: string
|
||||||
|
listedOnly?: boolean
|
||||||
|
includePrivate?: boolean
|
||||||
|
includeRedirects?: boolean
|
||||||
|
preview?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePostPayload {
|
||||||
|
title: string
|
||||||
|
slug?: string | null
|
||||||
|
description?: string | null
|
||||||
|
content?: string | null
|
||||||
|
category?: string | null
|
||||||
|
tags?: string[]
|
||||||
|
postType?: string | null
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePostPayload {
|
||||||
|
title?: string | null
|
||||||
|
slug: string
|
||||||
|
description?: string | null
|
||||||
|
content?: string | null
|
||||||
|
category?: string | null
|
||||||
|
tags?: unknown
|
||||||
|
postType?: string | null
|
||||||
|
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 {
|
||||||
|
slug: string
|
||||||
|
path: string
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownDeleteResponse {
|
||||||
|
slug: string
|
||||||
|
deleted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownImportResponse {
|
||||||
|
count: number
|
||||||
|
slugs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentRecord {
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
id: number
|
||||||
|
post_id: string | null
|
||||||
|
post_slug: string | null
|
||||||
|
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
|
||||||
|
paragraph_excerpt: string | null
|
||||||
|
reply_to: string | null
|
||||||
|
reply_to_comment_id: number | null
|
||||||
|
approved: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentListQuery {
|
||||||
|
postId?: string
|
||||||
|
postSlug?: string
|
||||||
|
scope?: string
|
||||||
|
paragraphKey?: string
|
||||||
|
approved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCommentPayload {
|
||||||
|
post_id?: string | null
|
||||||
|
post_slug?: string | null
|
||||||
|
author?: string | null
|
||||||
|
email?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
content?: string | null
|
||||||
|
reply_to?: string | null
|
||||||
|
reply_to_comment_id?: number | null
|
||||||
|
scope?: string | null
|
||||||
|
paragraph_key?: string | null
|
||||||
|
paragraph_excerpt?: string | null
|
||||||
|
approved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendLinkRecord {
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
id: number
|
||||||
|
site_name: string | null
|
||||||
|
site_url: string
|
||||||
|
avatar_url: string | null
|
||||||
|
description: string | null
|
||||||
|
category: string | null
|
||||||
|
status: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendLinkListQuery {
|
||||||
|
status?: string
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendLinkPayload {
|
||||||
|
siteName?: string | null
|
||||||
|
siteUrl: string
|
||||||
|
avatarUrl?: string | null
|
||||||
|
description?: string | null
|
||||||
|
category?: string | null
|
||||||
|
status?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewRecord {
|
||||||
|
id: number
|
||||||
|
title: string | null
|
||||||
|
review_type: string | null
|
||||||
|
rating: number | null
|
||||||
|
review_date: string | null
|
||||||
|
status: string | null
|
||||||
|
description: string | null
|
||||||
|
tags: string | null
|
||||||
|
cover: string | null
|
||||||
|
link_url: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReviewPayload {
|
||||||
|
title: string
|
||||||
|
review_type: string
|
||||||
|
rating: number
|
||||||
|
review_date: string
|
||||||
|
status: string
|
||||||
|
description: string
|
||||||
|
tags: string[]
|
||||||
|
cover: string
|
||||||
|
link_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReviewPayload {
|
||||||
|
title?: string
|
||||||
|
review_type?: string
|
||||||
|
rating?: number
|
||||||
|
review_date?: string
|
||||||
|
status?: string
|
||||||
|
description?: string
|
||||||
|
tags?: string[]
|
||||||
|
cover?: string
|
||||||
|
link_url?: string | null
|
||||||
|
}
|
||||||
6
admin/src/lib/utils.ts
Normal file
6
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
11
admin/src/main.tsx
Normal file
11
admin/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
563
admin/src/pages/analytics-page.tsx
Normal file
563
admin/src/pages/analytics-page.tsx
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } 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} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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-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">{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
admin/src/pages/audit-page.tsx
Normal file
166
admin/src/pages/audit-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1003
admin/src/pages/comments-page.tsx
Normal file
1003
admin/src/pages/comments-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
437
admin/src/pages/dashboard-page.tsx
Normal file
437
admin/src/pages/dashboard-page.tsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
BrainCircuit,
|
||||||
|
Clock3,
|
||||||
|
FolderTree,
|
||||||
|
MessageSquareWarning,
|
||||||
|
RefreshCcw,
|
||||||
|
Rss,
|
||||||
|
Star,
|
||||||
|
Tags,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { startTransition, useCallback, useEffect, 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 {
|
||||||
|
formatCommentScope,
|
||||||
|
formatPostStatus,
|
||||||
|
formatFriendLinkStatus,
|
||||||
|
formatPostType,
|
||||||
|
formatPostVisibility,
|
||||||
|
formatReviewStatus,
|
||||||
|
formatReviewType,
|
||||||
|
} from '@/lib/admin-format'
|
||||||
|
import type { AdminDashboardResponse } from '@/lib/types'
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
note,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
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="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
|
const loadDashboard = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
if (showToast) {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await adminApi.dashboard()
|
||||||
|
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 loadDashboard(false)
|
||||||
|
}, [loadDashboard])
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Skeleton className="h-[420px] rounded-3xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: '文章总数',
|
||||||
|
value: data.stats.total_posts,
|
||||||
|
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
||||||
|
icon: Rss,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '待审核评论',
|
||||||
|
value: data.stats.pending_comments,
|
||||||
|
note: '等待审核处理',
|
||||||
|
icon: MessageSquareWarning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 分块',
|
||||||
|
value: data.stats.ai_chunks,
|
||||||
|
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
||||||
|
icon: BrainCircuit,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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">
|
||||||
|
这里汇总了最重要的发布、审核和 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">
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
打开 AI 问答
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void loadDashboard(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-6 xl:grid-cols-[1.25fr_0.95fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>最近文章</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
最近同步到前台的文章内容。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>分类</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.recent_posts.map((post) => (
|
||||||
|
<TableRow key={post.id}>
|
||||||
|
<TableCell>
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>站点状态</CardTitle>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
|
||||||
|
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<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 flex items-end gap-2">
|
||||||
|
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
||||||
|
<Star className="mb-1 h-4 w-4 text-amber-500" />
|
||||||
|
</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 flex items-end gap-2">
|
||||||
|
<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 ?? '站点还没有建立过索引。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>待审核评论</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
在当前管理端直接查看审核队列。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>作者</TableHead>
|
||||||
|
<TableHead>范围</TableHead>
|
||||||
|
<TableHead>文章</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.pending_comments.map((comment) => (
|
||||||
|
<TableRow key={comment.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">{comment.author}</div>
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{comment.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="uppercase text-muted-foreground">
|
||||||
|
{formatCommentScope(comment.scope)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{comment.post_slug}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>待审核友链</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
等待审核和互链确认的申请。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="warning">{data.pending_friend_links.length} 条待处理</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{data.pending_friend_links.map((link) => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className="rounded-2xl border border-border/70 bg-background/70 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{link.site_name}</p>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
{link.site_url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{link.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
状态:{formatFriendLinkStatus(link.status)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{link.created_at}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近评测</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
最近同步到前台评测页的内容。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{data.recent_reviews.map((review) => (
|
||||||
|
<div
|
||||||
|
key={review.id}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
455
admin/src/pages/friend-links-page.tsx
Normal file
455
admin/src/pages/friend-links-page.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import { ExternalLink, Link2, 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 { 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 { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import { emptyToNull, formatDateTime, formatFriendLinkStatus } from '@/lib/admin-format'
|
||||||
|
import type { FriendLinkPayload, FriendLinkRecord } from '@/lib/types'
|
||||||
|
|
||||||
|
type FriendLinkFormState = {
|
||||||
|
siteName: string
|
||||||
|
siteUrl: string
|
||||||
|
avatarUrl: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFriendLinkForm: FriendLinkFormState = {
|
||||||
|
siteName: '',
|
||||||
|
siteUrl: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
status: 'pending',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormState(link: FriendLinkRecord): FriendLinkFormState {
|
||||||
|
return {
|
||||||
|
siteName: link.site_name ?? '',
|
||||||
|
siteUrl: link.site_url,
|
||||||
|
avatarUrl: link.avatar_url ?? '',
|
||||||
|
description: link.description ?? '',
|
||||||
|
category: link.category ?? '',
|
||||||
|
status: link.status ?? 'pending',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(form: FriendLinkFormState): FriendLinkPayload {
|
||||||
|
return {
|
||||||
|
siteName: emptyToNull(form.siteName),
|
||||||
|
siteUrl: form.siteUrl.trim(),
|
||||||
|
avatarUrl: emptyToNull(form.avatarUrl),
|
||||||
|
description: emptyToNull(form.description),
|
||||||
|
category: emptyToNull(form.category),
|
||||||
|
status: emptyToNull(form.status) ?? 'pending',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(status: string | null) {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return 'success' as const
|
||||||
|
case 'rejected':
|
||||||
|
return 'danger' as const
|
||||||
|
default:
|
||||||
|
return 'warning' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FriendLinksPage() {
|
||||||
|
const [links, setLinks] = useState<FriendLinkRecord[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
|
const [form, setForm] = useState<FriendLinkFormState>(defaultFriendLinkForm)
|
||||||
|
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 [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
|
||||||
|
const loadLinks = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
if (showToast) {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await adminApi.listFriendLinks()
|
||||||
|
startTransition(() => {
|
||||||
|
setLinks(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 loadLinks(false)
|
||||||
|
}, [loadLinks])
|
||||||
|
|
||||||
|
const filteredLinks = useMemo(() => {
|
||||||
|
return links.filter((link) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm ||
|
||||||
|
[
|
||||||
|
link.site_name ?? '',
|
||||||
|
link.site_url,
|
||||||
|
link.category ?? '',
|
||||||
|
link.description ?? '',
|
||||||
|
link.status ?? '',
|
||||||
|
]
|
||||||
|
.join('\n')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase())
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === 'all' || (link.status ?? 'pending') === statusFilter
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [links, searchTerm, statusFilter])
|
||||||
|
|
||||||
|
const selectedLink = useMemo(
|
||||||
|
() => links.find((link) => link.id === selectedId) ?? null,
|
||||||
|
[links, selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
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={() => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setForm(defaultFriendLinkForm)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建友链
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => void loadLinks(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>
|
||||||
|
选择一条友链进行编辑,或者直接在右侧创建新记录。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
|
||||||
|
<Input
|
||||||
|
placeholder="按站点名、URL、分类或备注搜索"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="pending">待审核</option>
|
||||||
|
<option value="approved">已通过</option>
|
||||||
|
<option value="rejected">已拒绝</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[620px] rounded-3xl" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredLinks.map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(link.id)
|
||||||
|
setForm(toFormState(link))
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||||
|
selectedId === link.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-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{link.site_name ?? '未命名站点'}</span>
|
||||||
|
<Badge variant={statusBadgeVariant(link.status)}>
|
||||||
|
{formatFriendLinkStatus(link.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{link.site_url}</p>
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{link.description ?? '暂无简介。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted-foreground">
|
||||||
|
<p>{link.category ?? '未分类'}</p>
|
||||||
|
<p className="mt-1">{formatDateTime(link.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!filteredLinks.length ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
|
||||||
|
<Link2 className="h-8 w-8" />
|
||||||
|
<p>当前筛选条件下没有匹配的友链。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{selectedLink ? '编辑友链' : '新建友链'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
维护前台友链页依赖的互链地址、分类和审核状态。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{selectedLink ? (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={selectedLink.site_url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
访问站点
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{selectedLink ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={form.status === 'approved' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={form.status === 'pending' ? 'secondary' : 'outline'}
|
||||||
|
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
|
||||||
|
>
|
||||||
|
待审核
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={form.status === 'rejected' ? 'danger' : 'outline'}
|
||||||
|
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!form.siteUrl.trim()) {
|
||||||
|
toast.error('站点 URL 不能为空。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
const payload = toPayload(form)
|
||||||
|
if (selectedLink) {
|
||||||
|
const updated = await adminApi.updateFriendLink(selectedLink.id, payload)
|
||||||
|
startTransition(() => {
|
||||||
|
setSelectedId(updated.id)
|
||||||
|
setForm(toFormState(updated))
|
||||||
|
})
|
||||||
|
toast.success('友链已更新。')
|
||||||
|
} else {
|
||||||
|
const created = await adminApi.createFriendLink(payload)
|
||||||
|
startTransition(() => {
|
||||||
|
setSelectedId(created.id)
|
||||||
|
setForm(toFormState(created))
|
||||||
|
})
|
||||||
|
toast.success('友链已创建。')
|
||||||
|
}
|
||||||
|
await loadLinks(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '无法保存友链。')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : selectedLink ? '保存修改' : '创建友链'}
|
||||||
|
</Button>
|
||||||
|
{selectedLink ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm('确定删除这条友链吗?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true)
|
||||||
|
await adminApi.deleteFriendLink(selectedLink.id)
|
||||||
|
toast.success('友链已删除。')
|
||||||
|
setSelectedId(null)
|
||||||
|
setForm(defaultFriendLinkForm)
|
||||||
|
await loadLinks(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '无法删除友链。')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleting ? '删除中...' : '删除'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{selectedLink ? (
|
||||||
|
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
当前记录
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
创建于 {formatDateTime(selectedLink.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadgeVariant(selectedLink.status)}>
|
||||||
|
{formatFriendLinkStatus(selectedLink.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<FormField label="站点名称">
|
||||||
|
<Input
|
||||||
|
value={form.siteName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, siteName: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="站点 URL">
|
||||||
|
<Input
|
||||||
|
value={form.siteUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, siteUrl: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="头像 URL">
|
||||||
|
<Input
|
||||||
|
value={form.avatarUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="分类">
|
||||||
|
<Input
|
||||||
|
value={form.category}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, category: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<FormField label="状态">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
|
||||||
|
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
form.status === 'pending'
|
||||||
|
? 'border-amber-500/40 bg-amber-500/10 text-amber-700'
|
||||||
|
: 'border-border/70 bg-background/60 hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">待审核</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">保留在队列里继续观察。</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
|
||||||
|
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
form.status === 'approved'
|
||||||
|
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700'
|
||||||
|
: 'border-border/70 bg-background/60 hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">通过</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">前台会按已通过友链展示。</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
|
||||||
|
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||||
|
form.status === 'rejected'
|
||||||
|
? 'border-rose-500/40 bg-rose-500/10 text-rose-700'
|
||||||
|
: 'border-border/70 bg-background/60 hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">拒绝</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">保留记录,但不在前台展示。</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<FormField label="简介">
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, description: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
admin/src/pages/login-page.tsx
Normal file
124
admin/src/pages/login-page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { LockKeyhole, ShieldCheck } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
Termi 后台
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<CardTitle className="text-4xl leading-tight">
|
||||||
|
将后台从前台中拆分出来,同时保持迭代节奏不掉线。
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-xl text-base leading-7">
|
||||||
|
当前管理工作统一在这个独立后台中完成,后端专注提供 API、认证与业务规则。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
['React 应用', '独立后台界面'],
|
||||||
|
['shadcn/ui', '统一的组件基础'],
|
||||||
|
['Loco API', '后端继续专注数据与规则'],
|
||||||
|
].map(([title, description]) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="rounded-2xl border border-border/70 bg-background/75 p-4"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold">{title}</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle 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>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
449
admin/src/pages/media-page.tsx
Normal file
449
admin/src/pages/media-page.tsx
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import {
|
||||||
|
CheckSquare,
|
||||||
|
Copy,
|
||||||
|
Image as ImageIcon,
|
||||||
|
RefreshCcw,
|
||||||
|
Replace,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} 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 { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
formatCompressionPreview,
|
||||||
|
maybeCompressImageWithPrompt,
|
||||||
|
normalizeCoverImageWithPrompt,
|
||||||
|
} from '@/lib/image-compress'
|
||||||
|
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||||
|
|
||||||
|
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]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||||
|
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||||
|
|
||||||
|
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 })
|
||||||
|
startTransition(() => {
|
||||||
|
setItems(result.items)
|
||||||
|
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])
|
||||||
|
|
||||||
|
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 normalizeCover =
|
||||||
|
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
|
||||||
|
|
||||||
|
const result: File[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
const compressed = normalizeCover
|
||||||
|
? await normalizeCoverImageWithPrompt(file, {
|
||||||
|
quality: safeQuality,
|
||||||
|
ask: true,
|
||||||
|
contextLabel: `封面规范化上传(${file.name})`,
|
||||||
|
})
|
||||||
|
: await maybeCompressImageWithPrompt(file, {
|
||||||
|
quality: safeQuality,
|
||||||
|
ask: true,
|
||||||
|
contextLabel: `媒体库上传(${file.name})`,
|
||||||
|
})
|
||||||
|
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}
|
||||||
|
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="uploads/">通用上传</option>
|
||||||
|
</Select>
|
||||||
|
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
|
||||||
|
<option value="post-covers/">上传到文章封面</option>
|
||||||
|
<option value="review-covers/">上传到评测封面</option>
|
||||||
|
<option value="uploads/">上传到通用目录</option>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="按对象 key 搜索"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||||||
|
<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>
|
||||||
|
<Input
|
||||||
|
className="w-[96px]"
|
||||||
|
value={compressQuality}
|
||||||
|
onChange={(event) => setCompressQuality(event.target.value)}
|
||||||
|
placeholder="0.82"
|
||||||
|
disabled={!compressBeforeUpload}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={!uploadFiles.length || uploading}
|
||||||
|
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 封面,并优先转成 AVIF/WebP。'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{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}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={item.key} className="overflow-hidden">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<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}
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
admin/src/pages/post-compare-page.tsx
Normal file
166
admin/src/pages/post-compare-page.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
|
||||||
|
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||||
|
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 { countLineDiff } from '@/lib/markdown-diff'
|
||||||
|
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
|
||||||
|
|
||||||
|
type CompareState = {
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
path: string
|
||||||
|
savedMarkdown: string
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URLSearchParams(window.location.search).get('draftKey')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||||
|
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||||
|
const [state, setState] = useState<CompareState | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||||
|
const [post, markdown] = await Promise.all([
|
||||||
|
adminApi.getPostBySlug(slug),
|
||||||
|
adminApi.getPostMarkdown(slug),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setState({
|
||||||
|
title: post.title ?? slug,
|
||||||
|
slug,
|
||||||
|
path: markdown.path,
|
||||||
|
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
|
||||||
|
draftMarkdown: draft?.markdown ?? markdown.markdown,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (loadError) {
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(loadError instanceof ApiError ? loadError.message : '无法加载改动对比。')
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const diffStats = useMemo(() => {
|
||||||
|
if (!state) {
|
||||||
|
return { additions: 0, deletions: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return countLineDiff(state.savedMarkdown, state.draftMarkdown)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||||
|
<div className="mx-auto max-w-[1480px] space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant="secondary">独立对比窗口</Badge>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
|
{state?.title || '草稿改动对比'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
左侧是当前已保存的正文,右侧是你正在编辑的草稿。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant="success">+{diffStats.additions} 行</Badge>
|
||||||
|
<Badge variant="danger">-{diffStats.deletions} 行</Badge>
|
||||||
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-sm text-muted-foreground">正在加载改动内容...</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : error ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>改动对比加载失败</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : state ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<GitCompareArrows className="h-4 w-4" />
|
||||||
|
保存版本 vs 当前草稿
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{state.path}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<MarkdownWorkbench
|
||||||
|
value={state.draftMarkdown}
|
||||||
|
originalValue={state.savedMarkdown}
|
||||||
|
path={state.path}
|
||||||
|
mode="workspace"
|
||||||
|
visiblePanels={['diff']}
|
||||||
|
availablePanels={['diff']}
|
||||||
|
readOnly
|
||||||
|
preview={<></>}
|
||||||
|
originalLabel="已保存版本"
|
||||||
|
modifiedLabel="当前草稿"
|
||||||
|
onModeChange={() => {}}
|
||||||
|
onVisiblePanelsChange={() => {}}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
admin/src/pages/post-polish-page.tsx
Normal file
302
admin/src/pages/post-polish-page.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
|
||||||
|
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import {
|
||||||
|
configureMonaco,
|
||||||
|
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'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import { computeDiffHunks, applySelectedDiffHunks } from '@/lib/markdown-merge'
|
||||||
|
import {
|
||||||
|
loadDraftWindowSnapshot,
|
||||||
|
savePolishWindowResult,
|
||||||
|
type DraftWindowSnapshot,
|
||||||
|
} from '@/lib/post-draft-window'
|
||||||
|
|
||||||
|
type PolishTarget = 'editor' | 'create'
|
||||||
|
|
||||||
|
function getDraftKey() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URLSearchParams(window.location.search).get('draftKey')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTarget(): PolishTarget {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new URLSearchParams(window.location.search).get('target')
|
||||||
|
return value === 'create' ? 'create' : 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplyMessage(draftKey: string, markdown: string, target: PolishTarget) {
|
||||||
|
return {
|
||||||
|
type: 'termi-admin-post-polish-apply',
|
||||||
|
draftKey,
|
||||||
|
markdown,
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostPolishPage() {
|
||||||
|
const draftKey = getDraftKey()
|
||||||
|
const target = getTarget()
|
||||||
|
const [snapshot, setSnapshot] = useState<DraftWindowSnapshot | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [polishing, setPolishing] = useState(false)
|
||||||
|
const [polishedMarkdown, setPolishedMarkdown] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const draft = loadDraftWindowSnapshot(draftKey)
|
||||||
|
if (!draft) {
|
||||||
|
setError('没有找到要润色的草稿快照,请从文章编辑页重新打开 AI 润色窗口。')
|
||||||
|
} else {
|
||||||
|
startTransition(() => {
|
||||||
|
setSnapshot(draft)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}, [draftKey])
|
||||||
|
|
||||||
|
const originalMarkdown = snapshot?.markdown ?? ''
|
||||||
|
const hunks = useMemo(
|
||||||
|
() => (polishedMarkdown ? computeDiffHunks(originalMarkdown, polishedMarkdown) : []),
|
||||||
|
[originalMarkdown, polishedMarkdown],
|
||||||
|
)
|
||||||
|
const mergedMarkdown = useMemo(
|
||||||
|
() => applySelectedDiffHunks(originalMarkdown, hunks, selectedIds),
|
||||||
|
[hunks, originalMarkdown, selectedIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyAll = () => {
|
||||||
|
setSelectedIds(new Set(hunks.map((hunk) => hunk.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepOriginal = () => {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyToParent = () => {
|
||||||
|
if (!draftKey) {
|
||||||
|
toast.error('当前窗口缺少草稿标识,无法回填。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = savePolishWindowResult(draftKey, mergedMarkdown, target)
|
||||||
|
window.opener?.postMessage(buildApplyMessage(draftKey, mergedMarkdown, target), window.location.origin)
|
||||||
|
toast.success('已把 AI 润色结果回填到原编辑器。')
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||||
|
<div className="mx-auto max-w-[1560px] space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant="secondary">AI 润色工作台</Badge>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
|
{snapshot?.title || 'AI 润色与选择性合并'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
左边保留润色前的原稿,右边是当前选中的合并结果。你可以先生成 AI 润色稿,再按改动块决定要保留哪些内容。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!snapshot || polishing}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!snapshot) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPolishing(true)
|
||||||
|
const result = await adminApi.polishPostMarkdown(snapshot.markdown)
|
||||||
|
const nextHunks = computeDiffHunks(snapshot.markdown, result.polished_markdown)
|
||||||
|
startTransition(() => {
|
||||||
|
setPolishedMarkdown(result.polished_markdown)
|
||||||
|
setSelectedIds(new Set(nextHunks.map((hunk) => hunk.id)))
|
||||||
|
})
|
||||||
|
toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`)
|
||||||
|
} catch (requestError) {
|
||||||
|
toast.error(requestError instanceof ApiError ? requestError.message : 'AI 润色失败。')
|
||||||
|
} finally {
|
||||||
|
setPolishing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
{polishing ? '润色中...' : polishedMarkdown ? '重新生成润色稿' : '生成 AI 润色稿'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" disabled={!hunks.length} onClick={applyAll}>
|
||||||
|
<CheckCheck className="h-4 w-4" />
|
||||||
|
全部采用
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" disabled={!hunks.length} onClick={keepOriginal}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
全部还原
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!hunks.length} onClick={applyToParent}>
|
||||||
|
<WandSparkles className="h-4 w-4" />
|
||||||
|
应用到原编辑器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-sm text-muted-foreground">正在加载草稿快照...</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : error ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI 润色窗口加载失败</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : snapshot ? (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.14fr_0.86fr]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>润色前 vs 当前合并结果</CardTitle>
|
||||||
|
<CardDescription>{snapshot.path}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant="secondary">改动块 {hunks.length}</Badge>
|
||||||
|
<Badge variant="success">已采用 {selectedIds.size}</Badge>
|
||||||
|
<Badge variant="outline">目标 {target === 'create' ? '新建草稿' : '现有文章'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e]">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
<span>润色前原稿</span>
|
||||||
|
<span>当前合并结果</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-[560px]">
|
||||||
|
<LazyDiffEditor
|
||||||
|
height="100%"
|
||||||
|
language="markdown"
|
||||||
|
original={originalMarkdown}
|
||||||
|
modified={mergedMarkdown}
|
||||||
|
originalModelPath={`${snapshot.path}#ai-original`}
|
||||||
|
modifiedModelPath={`${snapshot.path}#ai-merged`}
|
||||||
|
keepCurrentOriginalModel
|
||||||
|
keepCurrentModifiedModel
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={configureMonaco}
|
||||||
|
options={{
|
||||||
|
...sharedOptions,
|
||||||
|
originalEditable: false,
|
||||||
|
readOnly: true,
|
||||||
|
renderSideBySide: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>当前合并结果预览</CardTitle>
|
||||||
|
<CardDescription>边挑选改动,边查看最终会保存成什么样。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[420px] overflow-hidden rounded-[28px] border border-slate-200 bg-white">
|
||||||
|
<MarkdownPreview markdown={mergedMarkdown || originalMarkdown} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>改动块选择</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
每一块都可以单独采用或保留原文,合并结果会立即同步到右侧 diff 和预览。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{!polishedMarkdown ? (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border/70 px-5 py-10 text-sm text-muted-foreground">
|
||||||
|
先点“生成 AI 润色稿”,这里才会出现可选的改动块。
|
||||||
|
</div>
|
||||||
|
) : hunks.length ? (
|
||||||
|
hunks.map((hunk, index) => {
|
||||||
|
const accepted = selectedIds.has(hunk.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={hunk.id}
|
||||||
|
className={`rounded-3xl border p-4 transition ${
|
||||||
|
accepted
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10'
|
||||||
|
: 'border-border/70 bg-background/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">改动块 {index + 1}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
原文 {hunk.originalStart}-{Math.max(hunk.originalEnd, hunk.originalStart - 1)} 行
|
||||||
|
,润色稿 {hunk.modifiedStart}-{Math.max(hunk.modifiedEnd, hunk.modifiedStart - 1)} 行
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={accepted ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(hunk.id)) {
|
||||||
|
next.delete(hunk.id)
|
||||||
|
} else {
|
||||||
|
next.add(hunk.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{accepted ? '已采用' : '采用这块'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 rounded-2xl border border-border/60 bg-background/70 px-3 py-2 text-xs leading-6 text-muted-foreground">
|
||||||
|
{hunk.preview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl border border-border/70 px-5 py-10 text-sm text-muted-foreground">
|
||||||
|
AI 已返回结果,但没有检测到行级差异。可以直接应用,或者重新生成一次。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
admin/src/pages/post-preview-page.tsx
Normal file
166
admin/src/pages/post-preview-page.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { ExternalLink, RefreshCcw } from 'lucide-react'
|
||||||
|
import { startTransition, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||||
|
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||||
|
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 = {
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
path: string
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URLSearchParams(window.location.search).get('draftKey')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||||
|
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||||
|
const [state, setState] = useState<PreviewState | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||||
|
|
||||||
|
if (draft && draft.slug === slug) {
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setState({
|
||||||
|
title: draft.title,
|
||||||
|
slug: draft.slug,
|
||||||
|
path: draft.path,
|
||||||
|
markdown: draft.markdown,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [post, markdown] = await Promise.all([
|
||||||
|
adminApi.getPostBySlug(slug),
|
||||||
|
adminApi.getPostMarkdown(slug),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setState({
|
||||||
|
title: post.title ?? slug,
|
||||||
|
slug,
|
||||||
|
path: markdown.path,
|
||||||
|
markdown: markdown.markdown,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (loadError) {
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(loadError instanceof ApiError ? loadError.message : '无法加载预览内容。')
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||||
|
<div className="mx-auto max-w-[1400px] space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant="secondary">独立预览窗口</Badge>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
|
{state?.title || '文章预览'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
|
这里展示的是当前草稿的渲染效果,不会打断主编辑器里的输入位置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
{slug ? (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={buildFrontendUrl(`/articles/${slug}`)} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
打开前台页面
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-sm text-muted-foreground">正在加载预览内容...</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : error ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>预览加载失败</CardTitle>
|
||||||
|
<CardDescription>{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : state ? (
|
||||||
|
<MarkdownWorkbench
|
||||||
|
value={state.markdown}
|
||||||
|
originalValue=""
|
||||||
|
path={state.path}
|
||||||
|
mode="workspace"
|
||||||
|
visiblePanels={['preview']}
|
||||||
|
availablePanels={['preview']}
|
||||||
|
readOnly
|
||||||
|
preview={<MarkdownPreview markdown={state.markdown} />}
|
||||||
|
onModeChange={() => {}}
|
||||||
|
onVisiblePanelsChange={() => {}}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3233
admin/src/pages/posts-page.tsx
Normal file
3233
admin/src/pages/posts-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
688
admin/src/pages/reviews-page.tsx
Normal file
688
admin/src/pages/reviews-page.tsx
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||||
|
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form-field'
|
||||||
|
import { 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 { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
csvToList,
|
||||||
|
formatDateTime,
|
||||||
|
formatReviewStatus,
|
||||||
|
formatReviewType,
|
||||||
|
reviewTagsToList,
|
||||||
|
} from '@/lib/admin-format'
|
||||||
|
import {
|
||||||
|
formatCompressionPreview,
|
||||||
|
normalizeCoverImageWithPrompt,
|
||||||
|
} from '@/lib/image-compress'
|
||||||
|
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||||
|
|
||||||
|
type ReviewFormState = {
|
||||||
|
title: string
|
||||||
|
reviewType: string
|
||||||
|
rating: string
|
||||||
|
reviewDate: string
|
||||||
|
status: string
|
||||||
|
description: string
|
||||||
|
tags: string
|
||||||
|
cover: string
|
||||||
|
linkUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewDescriptionPolishState = {
|
||||||
|
originalDescription: string
|
||||||
|
polishedDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultReviewForm: ReviewFormState = {
|
||||||
|
title: '',
|
||||||
|
reviewType: 'book',
|
||||||
|
rating: '4',
|
||||||
|
reviewDate: '',
|
||||||
|
status: 'published',
|
||||||
|
description: '',
|
||||||
|
tags: '',
|
||||||
|
cover: '',
|
||||||
|
linkUrl: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormState(review: ReviewRecord): ReviewFormState {
|
||||||
|
return {
|
||||||
|
title: review.title ?? '',
|
||||||
|
reviewType: review.review_type ?? 'book',
|
||||||
|
rating: String(review.rating ?? 4),
|
||||||
|
reviewDate: review.review_date ?? '',
|
||||||
|
status: review.status ?? 'published',
|
||||||
|
description: review.description ?? '',
|
||||||
|
tags: reviewTagsToList(review.tags).join(', '),
|
||||||
|
cover: review.cover ?? '',
|
||||||
|
linkUrl: review.link_url ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCreatePayload(form: ReviewFormState): CreateReviewPayload {
|
||||||
|
return {
|
||||||
|
title: form.title.trim(),
|
||||||
|
review_type: form.reviewType,
|
||||||
|
rating: Number(form.rating),
|
||||||
|
review_date: form.reviewDate,
|
||||||
|
status: form.status,
|
||||||
|
description: form.description.trim(),
|
||||||
|
tags: csvToList(form.tags),
|
||||||
|
cover: form.cover.trim(),
|
||||||
|
link_url: form.linkUrl.trim() || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUpdatePayload(form: ReviewFormState): UpdateReviewPayload {
|
||||||
|
return {
|
||||||
|
title: form.title.trim(),
|
||||||
|
review_type: form.reviewType,
|
||||||
|
rating: Number(form.rating),
|
||||||
|
review_date: form.reviewDate,
|
||||||
|
status: form.status,
|
||||||
|
description: form.description.trim(),
|
||||||
|
tags: csvToList(form.tags),
|
||||||
|
cover: form.cover.trim(),
|
||||||
|
link_url: form.linkUrl.trim() || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewsPage() {
|
||||||
|
const [reviews, setReviews] = useState<ReviewRecord[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
|
const [form, setForm] = useState<ReviewFormState>(defaultReviewForm)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
const [polishingDescription, setPolishingDescription] = useState(false)
|
||||||
|
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const loadReviews = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
if (showToast) {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await adminApi.listReviews()
|
||||||
|
startTransition(() => {
|
||||||
|
setReviews(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 loadReviews(false)
|
||||||
|
}, [loadReviews])
|
||||||
|
|
||||||
|
const filteredReviews = useMemo(() => {
|
||||||
|
return reviews.filter((review) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm ||
|
||||||
|
[
|
||||||
|
review.title ?? '',
|
||||||
|
review.review_type ?? '',
|
||||||
|
review.description ?? '',
|
||||||
|
review.tags ?? '',
|
||||||
|
review.status ?? '',
|
||||||
|
]
|
||||||
|
.join('\n')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase())
|
||||||
|
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === 'all' || (review.status ?? 'published') === statusFilter
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [reviews, searchTerm, statusFilter])
|
||||||
|
|
||||||
|
const selectedReview = useMemo(
|
||||||
|
() => reviews.find((review) => review.id === selectedId) ?? null,
|
||||||
|
[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])
|
||||||
|
|
||||||
|
const uploadReviewCover = useCallback(async (file: File) => {
|
||||||
|
try {
|
||||||
|
setUploadingCover(true)
|
||||||
|
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||||
|
quality: 0.82,
|
||||||
|
ask: true,
|
||||||
|
contextLabel: '评测封面规范化上传',
|
||||||
|
})
|
||||||
|
if (compressed.preview) {
|
||||||
|
toast.message(formatCompressionPreview(compressed.preview))
|
||||||
|
}
|
||||||
|
const result = await adminApi.uploadReviewCoverImage(compressed.file)
|
||||||
|
startTransition(() => {
|
||||||
|
setForm((current) => ({ ...current, cover: result.url }))
|
||||||
|
})
|
||||||
|
toast.success('评测封面已上传到 R2。')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
|
||||||
|
} finally {
|
||||||
|
setUploadingCover(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<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={() => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setForm(defaultReviewForm)
|
||||||
|
setDescriptionPolish(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建评测
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => void loadReviews(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>
|
||||||
|
选择已有评测进行编辑,或者在右侧直接创建新条目。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
|
||||||
|
<Input
|
||||||
|
placeholder="按标题、媒介、简介、标签或状态搜索"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="published">已发布</option>
|
||||||
|
<option value="draft">草稿</option>
|
||||||
|
<option value="archived">已归档</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[620px] rounded-3xl" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredReviews.map((review) => (
|
||||||
|
<button
|
||||||
|
key={review.id}
|
||||||
|
type="button"
|
||||||
|
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
|
||||||
|
? '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-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{review.title ?? '未命名评测'}</span>
|
||||||
|
<Badge variant="outline">{formatReviewType(review.review_type)}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{review.description ?? '暂无简介。'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{reviewTagsToList(review.tags).join(', ') || '暂无标签'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xl font-semibold">{review.rating ?? 0}/5</div>
|
||||||
|
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{formatReviewStatus(review.status)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(review.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!filteredReviews.length ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
|
||||||
|
<BookOpenText className="h-8 w-8" />
|
||||||
|
<p>当前筛选条件下没有匹配的评测。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{selectedReview ? '编辑评测' : '新建评测'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
维护前台评测页直接读取的展示字段。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
toast.error('标题不能为空。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.reviewDate) {
|
||||||
|
toast.error('评测日期不能为空。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
if (selectedReview) {
|
||||||
|
const updated = await adminApi.updateReview(
|
||||||
|
selectedReview.id,
|
||||||
|
toUpdatePayload(form),
|
||||||
|
)
|
||||||
|
startTransition(() => {
|
||||||
|
setSelectedId(updated.id)
|
||||||
|
setForm(toFormState(updated))
|
||||||
|
setDescriptionPolish(null)
|
||||||
|
})
|
||||||
|
toast.success('评测已更新。')
|
||||||
|
} else {
|
||||||
|
const created = await adminApi.createReview(toCreatePayload(form))
|
||||||
|
startTransition(() => {
|
||||||
|
setSelectedId(created.id)
|
||||||
|
setForm(toFormState(created))
|
||||||
|
setDescriptionPolish(null)
|
||||||
|
})
|
||||||
|
toast.success('评测已创建。')
|
||||||
|
}
|
||||||
|
await loadReviews(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '无法保存评测。')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : selectedReview ? '保存修改' : '创建评测'}
|
||||||
|
</Button>
|
||||||
|
{selectedReview ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm('确定删除这条评测吗?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true)
|
||||||
|
await adminApi.deleteReview(selectedReview.id)
|
||||||
|
toast.success('评测已删除。')
|
||||||
|
setSelectedId(null)
|
||||||
|
setForm(defaultReviewForm)
|
||||||
|
setDescriptionPolish(null)
|
||||||
|
await loadReviews(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleting ? '删除中...' : '删除'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{selectedReview ? (
|
||||||
|
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
当前记录
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
创建于 {formatDateTime(selectedReview.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<FormField label="标题">
|
||||||
|
<Input
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, title: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="评测类型">
|
||||||
|
<Select
|
||||||
|
value={form.reviewType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, reviewType: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="book">图书</option>
|
||||||
|
<option value="movie">电影</option>
|
||||||
|
<option value="game">游戏</option>
|
||||||
|
<option value="anime">动画</option>
|
||||||
|
<option value="music">音乐</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="评分">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
step="1"
|
||||||
|
value={form.rating}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, rating: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="评测日期">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={form.reviewDate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, reviewDate: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="状态">
|
||||||
|
<Select
|
||||||
|
value={form.status}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, status: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="published">已发布</option>
|
||||||
|
<option value="draft">草稿</option>
|
||||||
|
<option value="archived">已归档</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={form.cover}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={reviewCoverInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
void uploadReviewCover(file)
|
||||||
|
}
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={uploadingCover}
|
||||||
|
onClick={() => reviewCoverInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{uploadingCover ? '上传中...' : '上传到 R2'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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
|
||||||
|
type="url"
|
||||||
|
value={form.linkUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, linkUrl: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
|
||||||
|
<Input
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, tags: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<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"
|
||||||
|
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
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
420
admin/src/pages/revisions-page.tsx
Normal file
420
admin/src/pages/revisions-page.tsx
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
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
|
||||||
|
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)}>
|
||||||
|
<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}
|
||||||
|
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">
|
||||||
|
full:整篇恢复;markdown:只恢复正文;metadata:只恢复 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>
|
||||||
|
}
|
||||||
1421
admin/src/pages/site-settings-page.tsx
Normal file
1421
admin/src/pages/site-settings-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
499
admin/src/pages/subscriptions-page.tsx
Normal file
499
admin/src/pages/subscriptions-page.tsx
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } 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 { 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 { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
|
||||||
|
|
||||||
|
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' },
|
||||||
|
] 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 || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [form, setForm] = useState(emptyForm())
|
||||||
|
|
||||||
|
const loadData = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
if (showToast) {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
const [nextSubscriptions, nextDeliveries] = await Promise.all([
|
||||||
|
adminApi.listSubscriptions(),
|
||||||
|
adminApi.listSubscriptionDeliveries(),
|
||||||
|
])
|
||||||
|
startTransition(() => {
|
||||||
|
setSubscriptions(nextSubscriptions)
|
||||||
|
setDeliveries(nextDeliveries)
|
||||||
|
})
|
||||||
|
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 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])
|
||||||
|
|
||||||
|
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">订阅中心 / 异步投递 / Digest</h2>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
|
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy 推送目标;当前投递走异步队列,并支持 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}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setDigesting('weekly')
|
||||||
|
const result = await adminApi.sendSubscriptionDigest('weekly')
|
||||||
|
toast.success(`周报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||||
|
await loadData(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
|
||||||
|
} finally {
|
||||||
|
setDigesting(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
{digesting === 'weekly' ? '入队中...' : '发送周报'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={digesting !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setDigesting('monthly')
|
||||||
|
const result = await adminApi.sendSubscriptionDigest('monthly')
|
||||||
|
toast.success(`月报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||||
|
await loadData(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
|
||||||
|
} finally {
|
||||||
|
setDigesting(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BellRing className="h-4 w-4" />
|
||||||
|
{digesting === 'monthly' ? '入队中...' : '发送月报'}
|
||||||
|
</Button>
|
||||||
|
</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' : '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>filters(JSON)</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>metadata(JSON,可选)</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()}>
|
||||||
|
{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>支持单条测试、编辑 filters / metadata,以及删除。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{subscriptions.length} 个</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>频道</TableHead>
|
||||||
|
<TableHead>目标</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>偏好</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subscriptions.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{item.channel_type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
|
||||||
|
<div>{item.target}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground/80">
|
||||||
|
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||||
|
{normalizePreview(item.filters)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(item.id)
|
||||||
|
setForm({
|
||||||
|
channelType: item.channel_type,
|
||||||
|
target: item.target,
|
||||||
|
displayName: item.display_name ?? '',
|
||||||
|
status: item.status,
|
||||||
|
notes: item.notes ?? '',
|
||||||
|
filtersText: prettyJson(item.filters),
|
||||||
|
metadataText: prettyJson(item.metadata),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={actioningId === item.id}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setActioningId(item.id)
|
||||||
|
await adminApi.testSubscription(item.id)
|
||||||
|
toast.success('测试通知已入队。')
|
||||||
|
await loadData(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||||
|
} finally {
|
||||||
|
setActioningId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={actioningId === item.id}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setActioningId(item.id)
|
||||||
|
await adminApi.deleteSubscription(item.id)
|
||||||
|
toast.success('订阅目标已删除。')
|
||||||
|
if (editingId === item.id) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
await loadData(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '删除失败。')
|
||||||
|
} finally {
|
||||||
|
setActioningId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>响应</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{deliveries.map((item) => (
|
||||||
|
<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 className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||||
|
{item.response_text ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
admin/src/vite-env.d.ts
vendored
Normal file
11
admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE?: string
|
||||||
|
readonly VITE_ADMIN_BASENAME?: string
|
||||||
|
readonly VITE_FRONTEND_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
31
admin/tsconfig.app.json
Normal file
31
admin/tsconfig.app.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
admin/tsconfig.json
Normal file
7
admin/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
admin/tsconfig.node.json
Normal file
26
admin/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
admin/vite.config.ts
Normal file
23
admin/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 4322,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:5150',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
target
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
*.out
|
||||||
|
*.err
|
||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
**/config/local.yaml
|
**/config/local.yaml
|
||||||
**/config/*.local.yaml
|
**/config/*.local.yaml
|
||||||
**/config/production.yaml
|
|
||||||
|
|
||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
|
|||||||
2520
backend/Cargo.lock
generated
2520
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -36,12 +36,14 @@ chrono = { version = "0.4" }
|
|||||||
validator = { version = "0.20" }
|
validator = { version = "0.20" }
|
||||||
uuid = { version = "1.6", features = ["v4"] }
|
uuid = { version = "1.6", features = ["v4"] }
|
||||||
include_dir = { version = "0.7" }
|
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"] }
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
|
||||||
|
fastembed = "5.1"
|
||||||
|
async-stream = "0.3"
|
||||||
|
base64 = "0.22"
|
||||||
|
aws-config = "1"
|
||||||
|
aws-sdk-s3 = "1"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "termi_api-cli"
|
name = "termi_api-cli"
|
||||||
|
|||||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM rust:1.88-bookworm AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY migration/Cargo.toml migration/Cargo.toml
|
||||||
|
COPY src src
|
||||||
|
COPY migration/src migration/src
|
||||||
|
COPY config config
|
||||||
|
COPY assets assets
|
||||||
|
|
||||||
|
RUN cargo build --release --locked --bin termi_api-cli
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates tzdata wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/termi_api-cli /usr/local/bin/termi_api-cli
|
||||||
|
COPY --from=builder /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"]
|
||||||
@@ -1,58 +1,37 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
|
```powershell
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo loco start
|
cargo loco start
|
||||||
```
|
```
|
||||||
|
|
||||||
```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
|
## 当前职责
|
||||||
|
|
||||||
▄ ▀
|
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 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`
|
||||||
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
|
||||||
██████ █████ ███ █████ █████ ███ ████▄
|
|
||||||
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
|
||||||
██████ █████ ███ ████ ███ █████ ███ ████▀
|
|
||||||
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
|
||||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
|
||||||
https://loco.rs
|
|
||||||
|
|
||||||
environment: development
|
## 生产部署
|
||||||
database: automigrate
|
|
||||||
logger: debug
|
|
||||||
compilation: debug
|
|
||||||
modes: server
|
|
||||||
|
|
||||||
listening on http://localhost:5150
|
生产环境推荐通过环境变量注入:
|
||||||
```
|
|
||||||
|
|
||||||
## Full Stack Serving
|
- `APP_BASE_URL`
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `REDIS_URL`
|
||||||
|
- `JWT_SECRET`
|
||||||
|
|
||||||
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
|
Docker / compose 相关示例见仓库根目录:
|
||||||
|
|
||||||
|
- `deploy/docker/compose.package.yml`
|
||||||
## 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/).
|
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
- id: 1
|
- id: 1
|
||||||
pid: 1
|
pid: 1
|
||||||
author: "Alice"
|
author: "林川"
|
||||||
email: "alice@example.com"
|
email: "linchuan@example.com"
|
||||||
content: "Great introduction! Looking forward to more content."
|
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。"
|
||||||
approved: true
|
approved: true
|
||||||
|
|
||||||
- id: 2
|
- id: 2
|
||||||
pid: 1
|
pid: 1
|
||||||
author: "Bob"
|
author: "阿青"
|
||||||
email: "bob@example.com"
|
email: "aqing@example.com"
|
||||||
content: "The terminal UI looks amazing. Love the design!"
|
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。"
|
||||||
approved: true
|
approved: true
|
||||||
|
|
||||||
- id: 3
|
- id: 3
|
||||||
pid: 2
|
pid: 2
|
||||||
author: "Charlie"
|
author: "周宁"
|
||||||
email: "charlie@example.com"
|
email: "zhouling@example.com"
|
||||||
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
|
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。"
|
||||||
approved: true
|
approved: true
|
||||||
|
|
||||||
- id: 4
|
- id: 4
|
||||||
pid: 3
|
pid: 3
|
||||||
author: "Diana"
|
author: "顾远"
|
||||||
email: "diana@example.com"
|
email: "guyuan@example.com"
|
||||||
content: "Astro is indeed fast. I've been using it for my personal blog too."
|
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
|
||||||
approved: true
|
approved: true
|
||||||
|
|
||||||
- id: 5
|
- id: 5
|
||||||
pid: 4
|
pid: 4
|
||||||
author: "Eve"
|
author: "清嘉"
|
||||||
email: "eve@example.com"
|
email: "qingjia@example.com"
|
||||||
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
|
content: "黄山记的序文很适合测试首屏摘要生成。"
|
||||||
approved: true
|
approved: true
|
||||||
|
|
||||||
- id: 6
|
- id: 6
|
||||||
pid: 5
|
pid: 5
|
||||||
author: "Frank"
|
author: "石霁"
|
||||||
email: "frank@example.com"
|
email: "shiji@example.com"
|
||||||
content: "Loco.rs looks promising. Might use it for my next project."
|
content: "想看看评测页和文章页共存时,搜索能不能把这类古文结果排在前面。"
|
||||||
approved: false
|
approved: false
|
||||||
|
|
||||||
- id: 7
|
- id: 7
|
||||||
pid: 2
|
pid: 3
|
||||||
author: "Grace"
|
author: "江禾"
|
||||||
email: "grace@example.com"
|
email: "jianghe@example.com"
|
||||||
content: "Would love to see more advanced Rust patterns in future posts."
|
content: "如果后续要做段落评论,这篇恒山记很适合,因为章节分段比较清晰。"
|
||||||
approved: true
|
approved: true
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
- id: 1
|
- id: 1
|
||||||
site_name: "Tech Blog Daily"
|
site_name: "山中札记"
|
||||||
site_url: "https://techblog.example.com"
|
site_url: "https://mountain-notes.example.com"
|
||||||
avatar_url: "https://techblog.example.com/avatar.png"
|
avatar_url: "https://mountain-notes.example.com/avatar.png"
|
||||||
description: "Daily tech news and tutorials"
|
description: "记录古籍、游记与自然地理的中文内容站。"
|
||||||
category: "tech"
|
category: "文化"
|
||||||
status: "approved"
|
status: "approved"
|
||||||
|
|
||||||
- id: 2
|
- id: 2
|
||||||
site_name: "Rustacean Station"
|
site_name: "旧书与远方"
|
||||||
site_url: "https://rustacean.example.com"
|
site_url: "https://oldbooks.example.com"
|
||||||
avatar_url: "https://rustacean.example.com/logo.png"
|
avatar_url: "https://oldbooks.example.com/logo.png"
|
||||||
description: "All things Rust programming"
|
description: "分享古典文学、读书笔记和旅行随笔。"
|
||||||
category: "tech"
|
category: "阅读"
|
||||||
status: "approved"
|
status: "approved"
|
||||||
|
|
||||||
- id: 3
|
- id: 3
|
||||||
site_name: "Design Patterns"
|
site_name: "山海数据局"
|
||||||
site_url: "https://designpatterns.example.com"
|
site_url: "https://shanhai-data.example.com"
|
||||||
avatar_url: "https://designpatterns.example.com/icon.png"
|
avatar_url: "https://shanhai-data.example.com/icon.png"
|
||||||
description: "UI/UX design inspiration"
|
description: "偏技术向的中文站点,关注搜索、知识库与可视化。"
|
||||||
category: "design"
|
category: "技术"
|
||||||
status: "approved"
|
status: "approved"
|
||||||
|
|
||||||
- id: 4
|
- id: 4
|
||||||
site_name: "Code Snippets"
|
site_name: "风物手册"
|
||||||
site_url: "https://codesnippets.example.com"
|
site_url: "https://fengwu.example.com"
|
||||||
description: "Useful code snippets for developers"
|
description: "整理地方风物、古迹与旅行地图。"
|
||||||
category: "dev"
|
category: "旅行"
|
||||||
status: "pending"
|
status: "pending"
|
||||||
|
|
||||||
- id: 5
|
- id: 5
|
||||||
site_name: "Web Dev Weekly"
|
site_name: "慢读周刊"
|
||||||
site_url: "https://webdevweekly.example.com"
|
site_url: "https://slowread.example.com"
|
||||||
avatar_url: "https://webdevweekly.example.com/favicon.png"
|
avatar_url: "https://slowread.example.com/favicon.png"
|
||||||
description: "Weekly web development newsletter"
|
description: "每周推荐中文长文、读书摘录与站点发现。"
|
||||||
category: "dev"
|
category: "内容"
|
||||||
status: "pending"
|
status: "pending"
|
||||||
|
|||||||
@@ -1,191 +1,109 @@
|
|||||||
- id: 1
|
- id: 1
|
||||||
pid: 1
|
pid: 1
|
||||||
title: "Welcome to Termi Blog"
|
title: "徐霞客游记·游太和山日记(上)"
|
||||||
slug: "welcome-to-termi"
|
slug: "welcome-to-termi"
|
||||||
content: |
|
content: |
|
||||||
# Welcome to Termi Blog
|
# 徐霞客游记·游太和山日记(上)
|
||||||
|
|
||||||
This is the first post on our new blog built with Astro and Loco.rs backend.
|
登仙猿岭。十馀里,有枯溪小桥,为郧县境,乃河南、湖广界。东五里,有池一泓,曰青泉,上源不见所自来,而下流淙淙,地又属淅川。
|
||||||
|
|
||||||
## Features
|
自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。
|
||||||
|
|
||||||
- 🚀 Fast performance with Astro
|
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
|
||||||
- 🎨 Terminal-style UI design
|
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。"
|
||||||
- 💬 Comments system
|
category: "古籍游记"
|
||||||
- 🔗 Friend links
|
|
||||||
- 🏷️ Tags and categories
|
|
||||||
|
|
||||||
## Code Example
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn main() {
|
|
||||||
println!("Hello, Termi!");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Stay tuned for more posts!
|
|
||||||
excerpt: "Welcome to our new blog built with Astro and Loco.rs backend."
|
|
||||||
category: "general"
|
|
||||||
published: true
|
published: true
|
||||||
pinned: true
|
pinned: true
|
||||||
tags:
|
tags:
|
||||||
- welcome
|
- 徐霞客
|
||||||
- astro
|
- 游记
|
||||||
- loco-rs
|
- 太和山
|
||||||
|
- 长文测试
|
||||||
|
|
||||||
- id: 2
|
- id: 2
|
||||||
pid: 2
|
pid: 2
|
||||||
title: "Rust Programming Tips"
|
title: "徐霞客游记·游太和山日记(下)"
|
||||||
slug: "rust-programming-tips"
|
slug: "building-blog-with-astro"
|
||||||
content: |
|
content: |
|
||||||
# Rust Programming Tips
|
# 徐霞客游记·游太和山日记(下)
|
||||||
|
|
||||||
Here are some essential tips for Rust developers:
|
更衣上金顶。瞻叩毕,天宇澄朗,下瞰诸峰,近者鹄峙,远者罗列,诚天真奥区也。
|
||||||
|
|
||||||
## 1. Ownership and Borrowing
|
遂从三天门之右小径下峡中。此径无级无索,乱峰离立,路穿其间,迥觉幽胜。三里馀,抵蜡烛峰右,泉涓涓溢出路旁,下为蜡烛涧。
|
||||||
|
|
||||||
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
从宫左趋雷公洞。洞在悬崖间。乃从北天门下,一径阴森,滴水、仙侣二岩,俱在路左,飞崖上突,泉滴沥于中。
|
||||||
|
excerpt: "《徐霞客游记》太和山下篇,包含琼台、南岩与五龙宫等段落。"
|
||||||
## 2. Pattern Matching
|
category: "古籍游记"
|
||||||
|
|
||||||
Use `match` expressions for exhaustive pattern matching:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
match result {
|
|
||||||
Ok(value) => println!("Success: {}", value),
|
|
||||||
Err(e) => println!("Error: {}", e),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Error Handling
|
|
||||||
|
|
||||||
Use `Result` and `Option` types effectively with the `?` operator.
|
|
||||||
|
|
||||||
Happy coding!
|
|
||||||
excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling."
|
|
||||||
category: "tech"
|
|
||||||
published: true
|
published: true
|
||||||
pinned: false
|
pinned: false
|
||||||
tags:
|
tags:
|
||||||
- rust
|
- 徐霞客
|
||||||
- programming
|
- 游记
|
||||||
- tips
|
- 太和山
|
||||||
|
- 长文测试
|
||||||
|
|
||||||
- id: 3
|
- id: 3
|
||||||
pid: 3
|
pid: 3
|
||||||
title: "Building a Blog with Astro"
|
title: "徐霞客游记·游恒山日记"
|
||||||
slug: "building-blog-with-astro"
|
slug: "rust-programming-tips"
|
||||||
content: |
|
content: |
|
||||||
# Building a Blog with Astro
|
# 徐霞客游记·游恒山日记
|
||||||
|
|
||||||
Astro is a modern static site generator that delivers lightning-fast performance.
|
出南山。大溪从山中俱来者,别而西去。余北驰平陆中,望外界之山,高不及台山十之四,其长缭绕如垣。
|
||||||
|
|
||||||
## Why Astro?
|
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
|
||||||
|
|
||||||
- **Zero JavaScript by default**: Ships less JavaScript to the client
|
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
|
||||||
- **Island Architecture**: Hydrate only interactive components
|
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。"
|
||||||
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
|
category: "古籍游记"
|
||||||
- **Great DX**: Excellent developer experience with hot module replacement
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm create astro@latest
|
|
||||||
cd my-astro-project
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Astro is perfect for content-focused websites like blogs.
|
|
||||||
excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs."
|
|
||||||
category: "tech"
|
|
||||||
published: true
|
published: true
|
||||||
pinned: false
|
pinned: false
|
||||||
tags:
|
tags:
|
||||||
- astro
|
- 徐霞客
|
||||||
- web-dev
|
- 恒山
|
||||||
- static-site
|
- 悬空寺
|
||||||
|
- 长文测试
|
||||||
|
|
||||||
- id: 4
|
- id: 4
|
||||||
pid: 4
|
pid: 4
|
||||||
title: "Terminal UI Design Principles"
|
title: "游黄山记(上)"
|
||||||
slug: "terminal-ui-design"
|
slug: "terminal-ui-design"
|
||||||
content: |
|
content: |
|
||||||
# Terminal UI Design Principles
|
# 游黄山记(上)
|
||||||
|
|
||||||
Terminal-style interfaces are making a comeback in modern web design.
|
辛巳春,余与程孟阳订黄山之游,约以梅花时相寻于武林之西溪。徐维翰书来劝驾,读之两腋欲举,遂挟吴去尘以行。
|
||||||
|
|
||||||
## Key Elements
|
黄山耸秀峻极,作镇一方。江南诸山,天台、天目为最,以地形准之,黄山之趾与二山齐。
|
||||||
|
|
||||||
1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono
|
自山口至汤口,山之麓也,登山之径于是始。汤泉之流,自紫石峰六百仞县布,其下有香泉溪。
|
||||||
2. **Dark Themes**: Black or dark backgrounds with vibrant text colors
|
excerpt: "钱谦益《游黄山记》上篇,包含序、记之一与记之二。"
|
||||||
3. **Command Prompts**: Use `$` or `>` as visual indicators
|
category: "古籍游记"
|
||||||
4. **ASCII Art**: Decorative elements using text characters
|
|
||||||
5. **Blinking Cursor**: The iconic terminal cursor
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Background: `#0d1117`
|
|
||||||
- Text: `#c9d1d9`
|
|
||||||
- Accent: `#58a6ff`
|
|
||||||
- Success: `#3fb950`
|
|
||||||
- Warning: `#d29922`
|
|
||||||
- Error: `#f85149`
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
|
||||||
excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces."
|
|
||||||
category: "design"
|
|
||||||
published: true
|
published: true
|
||||||
pinned: false
|
pinned: false
|
||||||
tags:
|
tags:
|
||||||
- design
|
- 钱谦益
|
||||||
- terminal
|
- 黄山
|
||||||
- ui
|
- 游记
|
||||||
|
- 长文测试
|
||||||
|
|
||||||
- id: 5
|
- id: 5
|
||||||
pid: 5
|
pid: 5
|
||||||
title: "Loco.rs Backend Framework"
|
title: "游黄山记(中)"
|
||||||
slug: "loco-rs-framework"
|
slug: "loco-rs-framework"
|
||||||
content: |
|
content: |
|
||||||
# Introduction to Loco.rs
|
# 游黄山记(中)
|
||||||
|
|
||||||
Loco.rs is a web and API framework for Rust inspired by Rails.
|
由祥符寺度石桥而北,逾慈光寺,行数里,径朱砂庵而上。过此取道钵盂、老人两峰之间,峰趾相并,两崖合遝,弥望削成。
|
||||||
|
|
||||||
## Features
|
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
|
||||||
|
|
||||||
- **MVC Architecture**: Model-View-Controller pattern
|
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
|
||||||
- **SeaORM Integration**: Powerful ORM for database operations
|
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。"
|
||||||
- **Background Jobs**: Built-in job processing
|
category: "古籍游记"
|
||||||
- **Authentication**: Ready-to-use auth system
|
|
||||||
- **CLI Generator**: Scaffold resources quickly
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo install loco
|
|
||||||
loco new myapp
|
|
||||||
cd myapp
|
|
||||||
cargo loco start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why Loco.rs?
|
|
||||||
|
|
||||||
- Opinionated but flexible
|
|
||||||
- Production-ready defaults
|
|
||||||
- Excellent documentation
|
|
||||||
- Active community
|
|
||||||
|
|
||||||
Perfect for building APIs and web applications in Rust.
|
|
||||||
excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust."
|
|
||||||
category: "tech"
|
|
||||||
published: true
|
published: true
|
||||||
pinned: false
|
pinned: false
|
||||||
tags:
|
tags:
|
||||||
- rust
|
- 钱谦益
|
||||||
- loco-rs
|
- 黄山
|
||||||
- backend
|
- 游记
|
||||||
- api
|
- 长文测试
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
- id: 1
|
- id: 1
|
||||||
title: "塞尔达传说:王国之泪"
|
title: "《漫长的季节》"
|
||||||
review_type: "game"
|
|
||||||
rating: 5
|
|
||||||
review_date: "2024-03-20"
|
|
||||||
status: "completed"
|
|
||||||
description: "开放世界的巅峰之作,究极手能力带来无限创意空间"
|
|
||||||
tags: ["Switch", "开放世界", "冒险"]
|
|
||||||
cover: "🎮"
|
|
||||||
|
|
||||||
- id: 2
|
|
||||||
title: "进击的巨人"
|
|
||||||
review_type: "anime"
|
|
||||||
rating: 5
|
|
||||||
review_date: "2023-11-10"
|
|
||||||
status: "completed"
|
|
||||||
description: "史诗级完结,剧情反转令人震撼"
|
|
||||||
tags: ["热血", "悬疑", "神作"]
|
|
||||||
cover: "🎭"
|
|
||||||
|
|
||||||
- id: 3
|
|
||||||
title: "赛博朋克 2077"
|
|
||||||
review_type: "game"
|
|
||||||
rating: 4
|
|
||||||
review_date: "2024-01-15"
|
|
||||||
status: "completed"
|
|
||||||
description: "夜之城的故事,虽然首发有问题但后续更新很棒"
|
|
||||||
tags: ["PC", "RPG", "科幻"]
|
|
||||||
cover: "🎮"
|
|
||||||
|
|
||||||
- id: 4
|
|
||||||
title: "三体"
|
|
||||||
review_type: "book"
|
|
||||||
rating: 5
|
|
||||||
review_date: "2023-08-05"
|
|
||||||
status: "completed"
|
|
||||||
description: "硬科幻巅峰,宇宙社会学的黑暗森林法则"
|
|
||||||
tags: ["科幻", "经典", "雨果奖"]
|
|
||||||
cover: "📚"
|
|
||||||
|
|
||||||
- id: 5
|
|
||||||
title: "星际穿越"
|
|
||||||
review_type: "movie"
|
review_type: "movie"
|
||||||
rating: 5
|
rating: 5
|
||||||
review_date: "2024-02-14"
|
review_date: "2024-03-20"
|
||||||
status: "completed"
|
status: "published"
|
||||||
description: "诺兰神作,五维空间和黑洞的视觉奇观"
|
description: "极有质感的中文悬疑剧,人物命运与时代氛围都很扎实。"
|
||||||
tags: ["科幻", "IMAX", "诺兰"]
|
tags: ["国产剧", "悬疑", "年度推荐"]
|
||||||
cover: "🎬"
|
cover: "/review-covers/the-long-season.svg"
|
||||||
|
|
||||||
- id: 6
|
- id: 2
|
||||||
title: "博德之门3"
|
title: "《十三邀》"
|
||||||
|
review_type: "movie"
|
||||||
|
rating: 4
|
||||||
|
review_date: "2024-01-10"
|
||||||
|
status: "published"
|
||||||
|
description: "更像一组人物观察样本,适合慢慢看,不适合倍速。"
|
||||||
|
tags: ["访谈", "人文", "纪实"]
|
||||||
|
cover: "/review-covers/thirteen-invites.svg"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
title: "《黑神话:悟空》"
|
||||||
review_type: "game"
|
review_type: "game"
|
||||||
rating: 5
|
rating: 5
|
||||||
review_date: "2024-04-01"
|
review_date: "2024-08-25"
|
||||||
status: "in-progress"
|
status: "published"
|
||||||
description: "CRPG的文艺复兴,骰子决定命运"
|
description: "美术和演出都很强,战斗手感也足够扎实,是非常好的中文游戏样本。"
|
||||||
tags: ["PC", "CRPG", "多人"]
|
tags: ["国产游戏", "动作", "神话"]
|
||||||
cover: "🎮"
|
cover: "/review-covers/black-myth-wukong.svg"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
title: "《置身事内》"
|
||||||
|
review_type: "book"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-02-18"
|
||||||
|
status: "published"
|
||||||
|
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。"
|
||||||
|
tags: ["经济", "非虚构", "中国"]
|
||||||
|
cover: "/review-covers/placed-within.svg"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
title: "《宇宙探索编辑部》"
|
||||||
|
review_type: "movie"
|
||||||
|
rating: 4
|
||||||
|
review_date: "2024-04-12"
|
||||||
|
status: "published"
|
||||||
|
description: "荒诞和真诚并存,气质很特别,很适合作为中文评论内容。"
|
||||||
|
tags: ["电影", "科幻", "荒诞"]
|
||||||
|
cover: "/review-covers/journey-to-the-west-editorial.svg"
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
title: "《疲惫生活中的英雄梦想》"
|
||||||
|
review_type: "music"
|
||||||
|
rating: 4
|
||||||
|
review_date: "2024-05-01"
|
||||||
|
status: "draft"
|
||||||
|
description: "适合深夜循环,文字和旋律都带一点诚恳的钝感。"
|
||||||
|
tags: ["音乐", "中文", "独立"]
|
||||||
|
cover: "/review-covers/hero-dreams-in-tired-life.svg"
|
||||||
|
|||||||
55
backend/assets/seeds/site_settings.yaml
Normal file
55
backend/assets/seeds/site_settings.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
- id: 1
|
||||||
|
site_name: "InitCool"
|
||||||
|
site_short_name: "Termi"
|
||||||
|
site_url: "https://init.cool"
|
||||||
|
site_title: "InitCool · 中文长文与 AI 搜索实验站"
|
||||||
|
site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。"
|
||||||
|
hero_title: "欢迎来到我的中文内容实验站"
|
||||||
|
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
|
||||||
|
owner_name: "InitCool"
|
||||||
|
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||||
|
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||||
|
owner_avatar_url: "https://github.com/limitcool.png"
|
||||||
|
social_github: "https://github.com/limitcool"
|
||||||
|
social_twitter: ""
|
||||||
|
social_email: "mailto:initcoool@gmail.com"
|
||||||
|
location: "中国香港"
|
||||||
|
tech_stack:
|
||||||
|
- "Rust"
|
||||||
|
- "Go"
|
||||||
|
- "Python"
|
||||||
|
- "Svelte"
|
||||||
|
- "Astro"
|
||||||
|
- "Loco.rs"
|
||||||
|
music_playlist:
|
||||||
|
- title: "山中来信"
|
||||||
|
artist: "InitCool Radio"
|
||||||
|
album: "站点默认歌单"
|
||||||
|
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
|
||||||
|
cover_image_url: "https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80"
|
||||||
|
accent_color: "#2f6b5f"
|
||||||
|
description: "适合文章阅读时循环播放的轻氛围曲。"
|
||||||
|
- title: "风吹松声"
|
||||||
|
artist: "InitCool Radio"
|
||||||
|
album: "站点默认歌单"
|
||||||
|
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3"
|
||||||
|
cover_image_url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80"
|
||||||
|
accent_color: "#8a5b35"
|
||||||
|
description: "偏木质感的器乐氛围,适合深夜浏览。"
|
||||||
|
- title: "夜航小记"
|
||||||
|
artist: "InitCool Radio"
|
||||||
|
album: "站点默认歌单"
|
||||||
|
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3"
|
||||||
|
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
|
||||||
|
accent_color: "#375a7f"
|
||||||
|
description: "节奏更明显一点,适合切换阅读状态。"
|
||||||
|
ai_enabled: false
|
||||||
|
paragraph_comments_enabled: true
|
||||||
|
ai_provider: "newapi"
|
||||||
|
ai_api_base: "https://91code.jiangnight.com/v1"
|
||||||
|
ai_api_key: "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571"
|
||||||
|
ai_chat_model: "gpt-5.4"
|
||||||
|
ai_embedding_model: "fastembed / local all-MiniLM-L6-v2"
|
||||||
|
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
|
||||||
|
ai_top_k: 4
|
||||||
|
ai_chunk_size: 1200
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<html><body>
|
|
||||||
not found :-(
|
|
||||||
</body></html>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,682 +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 {
|
|
||||||
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 {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% block page_scripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -1,63 +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.author }}</strong>
|
|
||||||
<span class="item-meta">{{ row.post_slug }}</span>
|
|
||||||
{% if row.frontend_url %}
|
|
||||||
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">跳到前台文章</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ row.content }}</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>
|
|
||||||
<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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -1,108 +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="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="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>
|
|
||||||
<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 %}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
{% extends "admin/base.html" %}
|
|
||||||
|
|
||||||
{% block main_content %}
|
|
||||||
<section class="form-panel">
|
|
||||||
<div class="table-head">
|
|
||||||
<div>
|
|
||||||
<h2>站点资料</h2>
|
|
||||||
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。</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">
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
|
||||||
</div>
|
|
||||||
<div class="field-hint" style="margin-top: 10px;">保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。</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");
|
|
||||||
|
|
||||||
function showNotice(message, kind) {
|
|
||||||
notice.textContent = message;
|
|
||||||
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
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("站点信息已保存。", "success");
|
|
||||||
} catch (error) {
|
|
||||||
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -31,8 +31,6 @@ server:
|
|||||||
folder:
|
folder:
|
||||||
uri: "/static"
|
uri: "/static"
|
||||||
path: "assets/static"
|
path: "assets/static"
|
||||||
# fallback to index.html which redirects to /admin
|
|
||||||
fallback: "assets/static/index.html"
|
|
||||||
|
|
||||||
# Worker Configuration
|
# Worker Configuration
|
||||||
workers:
|
workers:
|
||||||
|
|||||||
59
backend/config/production.yaml
Normal file
59
backend/config/production.yaml
Normal 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") }}
|
||||||
@@ -29,7 +29,6 @@ server:
|
|||||||
folder:
|
folder:
|
||||||
uri: "/static"
|
uri: "/static"
|
||||||
path: "assets/static"
|
path: "assets/static"
|
||||||
fallback: "assets/static/404.html"
|
|
||||||
|
|
||||||
# Worker Configuration
|
# Worker Configuration
|
||||||
workers:
|
workers:
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
title: Building a Blog with Astro
|
|
||||||
slug: building-blog-with-astro
|
|
||||||
description: Learn why Astro is the perfect choice for building fast, content-focused blogs.
|
|
||||||
category: tech
|
|
||||||
post_type: article
|
|
||||||
pinned: false
|
|
||||||
published: true
|
|
||||||
tags:
|
|
||||||
- astro
|
|
||||||
- web-dev
|
|
||||||
- static-site
|
|
||||||
---
|
|
||||||
|
|
||||||
# Building a Blog with Astro
|
|
||||||
|
|
||||||
Astro is a modern static site generator that delivers lightning-fast performance.
|
|
||||||
|
|
||||||
## Why Astro?
|
|
||||||
|
|
||||||
- Zero JavaScript by default
|
|
||||||
- Island Architecture
|
|
||||||
- Framework Agnostic
|
|
||||||
- Great DX
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm create astro@latest
|
|
||||||
cd my-astro-project
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Astro is perfect for content-focused websites like blogs.
|
|
||||||
242
backend/content/posts/canokeys.md
Normal file
242
backend/content/posts/canokeys.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
右上角`Add account` 增加`2FA`
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```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`算法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```shell
|
||||||
|
选择(11) ECC (set your own capabilities) # 设置自己的功能 主密钥只保留 Certify 功能,其他功能(Encr,Sign,Auth)使用子密钥
|
||||||
|
# 子密钥分成三份,分别获得三个不同的功能
|
||||||
|
# encr 解密功能
|
||||||
|
# sign 签名功能
|
||||||
|
# auth 登录验证功能
|
||||||
|
```
|
||||||
|
|
||||||
|
```shell
|
||||||
|
先选择 (S) Toggle the sign capability
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```
|
||||||
|
之后输入q 退出
|
||||||
|
```
|
||||||
|
|
||||||
|
键入1,选择默认算法
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
设置主密钥永不过期
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
填写信息,按照实际情况填写即可
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```
|
||||||
|
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`,方便直接使用。
|
||||||
67
backend/content/posts/ffmpeg.md
Normal file
67
backend/content/posts/ffmpeg.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
121
backend/content/posts/go-arm.md
Normal file
121
backend/content/posts/go-arm.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
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 了。
|
||||||
173
backend/content/posts/go-grpc.md
Normal file
173
backend/content/posts/go-grpc.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
98
backend/content/posts/go-xml.md
Normal file
98
backend/content/posts/go-xml.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
|
```
|
||||||
36
backend/content/posts/hugo.md
Normal file
36
backend/content/posts/hugo.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
67
backend/content/posts/linux-dhcp.md
Normal file
67
backend/content/posts/linux-dhcp.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user