merge: integrate blog platform admin and deploy stack
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 4s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 5s

This commit is contained in:
2026-03-31 21:53:06 +08:00
320 changed files with 60961 additions and 5678 deletions

View 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
View File

@@ -5,7 +5,24 @@
frontend/.astro/
frontend/dist/
frontend/node_modules/
admin/dist/
admin/node_modules/
mcp-server/node_modules/
backend/target/
backend/.loco-start.err.log
backend/.loco-start.out.log
backend/backend-start.log
backend/*.log
backend/*.err.log
backend/*.out.log
backend/storage/ai_embedding_models/
backend-start.err
backend-start.log
# local deployment/runtime artifacts
deploy/docker/.env
deploy/docker/config.yaml
admin/tmp-playwright.*
lighthouse-*/
lighthouse-*.json

148
README.md
View File

@@ -6,47 +6,81 @@ Monorepo for the Termi blog system.
```text
.
├─ admin/ # React + shadcn admin workspace
├─ 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
└─ .vscode/ # Editor workspace config
```
## Run
### Monorepo scripts
### Recommended
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
.\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
.\dev.ps1 -FrontendOnly
.\dev.ps1 -Only frontend -Spawn
```
Only backend:
```powershell
.\dev.ps1 -BackendOnly
```
Direct scripts:
Legacy aliases are still available and now just forward to `dev.ps1`:
```powershell
.\start-frontend.ps1
.\start-backend.ps1
.\start-admin.ps1
.\start-mcp.ps1
```
### Frontend
```powershell
cd frontend
npm install
npm run dev
pnpm install
pnpm dev
```
### Admin
```powershell
cd admin
pnpm install
pnpm dev
```
### 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
```
### 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
Recommended repository name: `termi-blog`

5
admin/.dockerignore Normal file
View File

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

24
admin/.gitignore vendored Normal file
View 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
View 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;"]

View File

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

23
admin/eslint.config.js Normal file
View 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
View 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
View File

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

3515
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
admin/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

9
admin/public/favicon.svg Normal file
View 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

View File

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

389
admin/src/App.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

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

View File

@@ -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>
)
}

View 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,
)
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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
View 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;
}

View 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
View 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',
}),
}

View File

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

View File

@@ -0,0 +1,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)}%`
}

View 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),
}
}

View 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`
}

View 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')
}

View 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
}
}

View File

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

768
admin/src/lib/types.ts Normal file
View 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
View 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
View 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>,
)

View 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>
)
}

View File

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

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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">
fullmarkdownmetadata frontmatter / SEO /
</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="space-y-2">
<LabelRow title={comparisonLabel} />
<Textarea
value={comparisonMarkdown}
readOnly
className="min-h-[280px] font-mono text-xs leading-6"
/>
</div>
<div className="space-y-2">
<LabelRow title={`版本 #${selected.item.id}`} />
<Textarea
value={selected.markdown ?? ''}
readOnly
className="min-h-[280px] font-mono text-xs leading-6"
/>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-10 text-center text-sm text-muted-foreground">
Diff
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function LabelRow({ title }: { title: string }) {
return <div className="text-sm font-medium text-foreground">{title}</div>
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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>filtersJSON</Label>
<Textarea
value={form.filtersText}
onChange={(event) => setForm((current) => ({ ...current, filtersText: event.target.value }))}
placeholder='{"event_types":["post.published","digest.weekly"]}'
className="min-h-32 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>metadataJSON</Label>
<Textarea
value={form.metadataText}
onChange={(event) => setForm((current) => ({ ...current, metadataText: event.target.value }))}
placeholder='{"owner":"ops","source":"manual"}'
className="min-h-28 font-mono text-xs"
/>
</div>
<div className="flex flex-wrap gap-3">
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
{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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
admin/tsconfig.node.json Normal file
View 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
View 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
View File

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

1
backend/.gitignore vendored
View File

@@ -1,6 +1,5 @@
**/config/local.yaml
**/config/*.local.yaml
**/config/production.yaml
# Generated by Cargo
# will have compiled files and executables

2520
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,12 +36,14 @@ chrono = { version = "0.4" }
validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" }
# view engine i18n
fluent-templates = { version = "0.13", features = ["tera"] }
unic-langid = { version = "0.9" }
# /view engine
axum-extra = { version = "0.10", features = ["form"] }
tower-http = { version = "0.6", features = ["cors"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
fastembed = "5.1"
async-stream = "0.3"
base64 = "0.22"
aws-config = "1"
aws-sdk-s3 = "1"
[[bin]]
name = "termi_api-cli"

32
backend/Dockerfile Normal file
View 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"]

View File

@@ -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.
## 本地启动
## Quick Start
```sh
```powershell
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
## 当前职责
▄ ▀
▀ ▄
▄ ▀ ▄ ▄ ▄▀
▄ ▀▄▄
▄ ▀ ▀ ▀▄▀█▄
▀█▄
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
██████ █████ ███ █████ ███ █████ ███ ▀█
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
██████ █████ ███ █████ █████ ███ ████▄
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
██████ █████ ███ ████ ███ █████ ███ ████▀
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
https://loco.rs
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 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`
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 相关示例见仓库根目录:
## Getting help
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
- `deploy/docker/compose.package.yml`

View File

@@ -1,48 +1,48 @@
- id: 1
pid: 1
author: "Alice"
email: "alice@example.com"
content: "Great introduction! Looking forward to more content."
author: "林川"
email: "linchuan@example.com"
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。"
approved: true
- id: 2
pid: 1
author: "Bob"
email: "bob@example.com"
content: "The terminal UI looks amazing. Love the design!"
author: "阿青"
email: "aqing@example.com"
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。"
approved: true
- id: 3
pid: 2
author: "Charlie"
email: "charlie@example.com"
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
author: "周宁"
email: "zhouling@example.com"
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。"
approved: true
- id: 4
pid: 3
author: "Diana"
email: "diana@example.com"
content: "Astro is indeed fast. I've been using it for my personal blog too."
author: "顾远"
email: "guyuan@example.com"
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
approved: true
- id: 5
pid: 4
author: "Eve"
email: "eve@example.com"
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
author: "清嘉"
email: "qingjia@example.com"
content: "黄山记的序文很适合测试首屏摘要生成。"
approved: true
- id: 6
pid: 5
author: "Frank"
email: "frank@example.com"
content: "Loco.rs looks promising. Might use it for my next project."
author: "石霁"
email: "shiji@example.com"
content: "想看看评测页和文章页共存时,搜索能不能把这类古文结果排在前面。"
approved: false
- id: 7
pid: 2
author: "Grace"
email: "grace@example.com"
content: "Would love to see more advanced Rust patterns in future posts."
pid: 3
author: "江禾"
email: "jianghe@example.com"
content: "如果后续要做段落评论,这篇恒山记很适合,因为章节分段比较清晰。"
approved: true

View File

@@ -1,38 +1,38 @@
- id: 1
site_name: "Tech Blog Daily"
site_url: "https://techblog.example.com"
avatar_url: "https://techblog.example.com/avatar.png"
description: "Daily tech news and tutorials"
category: "tech"
site_name: "山中札记"
site_url: "https://mountain-notes.example.com"
avatar_url: "https://mountain-notes.example.com/avatar.png"
description: "记录古籍、游记与自然地理的中文内容站。"
category: "文化"
status: "approved"
- id: 2
site_name: "Rustacean Station"
site_url: "https://rustacean.example.com"
avatar_url: "https://rustacean.example.com/logo.png"
description: "All things Rust programming"
category: "tech"
site_name: "旧书与远方"
site_url: "https://oldbooks.example.com"
avatar_url: "https://oldbooks.example.com/logo.png"
description: "分享古典文学、读书笔记和旅行随笔。"
category: "阅读"
status: "approved"
- id: 3
site_name: "Design Patterns"
site_url: "https://designpatterns.example.com"
avatar_url: "https://designpatterns.example.com/icon.png"
description: "UI/UX design inspiration"
category: "design"
site_name: "山海数据局"
site_url: "https://shanhai-data.example.com"
avatar_url: "https://shanhai-data.example.com/icon.png"
description: "偏技术向的中文站点,关注搜索、知识库与可视化。"
category: "技术"
status: "approved"
- id: 4
site_name: "Code Snippets"
site_url: "https://codesnippets.example.com"
description: "Useful code snippets for developers"
category: "dev"
site_name: "风物手册"
site_url: "https://fengwu.example.com"
description: "整理地方风物、古迹与旅行地图。"
category: "旅行"
status: "pending"
- id: 5
site_name: "Web Dev Weekly"
site_url: "https://webdevweekly.example.com"
avatar_url: "https://webdevweekly.example.com/favicon.png"
description: "Weekly web development newsletter"
category: "dev"
site_name: "慢读周刊"
site_url: "https://slowread.example.com"
avatar_url: "https://slowread.example.com/favicon.png"
description: "每周推荐中文长文、读书摘录与站点发现。"
category: "内容"
status: "pending"

View File

@@ -1,191 +1,109 @@
- id: 1
pid: 1
title: "Welcome to Termi Blog"
title: "徐霞客游记·游太和山日记(上)"
slug: "welcome-to-termi"
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
- 💬 Comments system
- 🔗 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"
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。"
category: "古籍游记"
published: true
pinned: true
tags:
- welcome
- astro
- loco-rs
- 徐霞客
- 游记
- 太和山
- 长文测试
- id: 2
pid: 2
title: "Rust Programming Tips"
slug: "rust-programming-tips"
title: "徐霞客游记·游太和山日记(下)"
slug: "building-blog-with-astro"
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.
## 2. Pattern Matching
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"
从宫左趋雷公洞。洞在悬崖间。乃从北天门下,一径阴森,滴水、仙侣二岩,俱在路左,飞崖上突,泉滴沥于中。
excerpt: "《徐霞客游记》太和山下篇,包含琼台、南岩与五龙宫等段落。"
category: "古籍游记"
published: true
pinned: false
tags:
- rust
- programming
- tips
- 徐霞客
- 游记
- 太和山
- 长文测试
- id: 3
pid: 3
title: "Building a Blog with Astro"
slug: "building-blog-with-astro"
title: "徐霞客游记·游恒山日记"
slug: "rust-programming-tips"
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
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
- **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"
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。"
category: "古籍游记"
published: true
pinned: false
tags:
- astro
- web-dev
- static-site
- 徐霞客
- 恒山
- 悬空寺
- 长文测试
- id: 4
pid: 4
title: "Terminal UI Design Principles"
title: "游黄山记(上)"
slug: "terminal-ui-design"
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
3. **Command Prompts**: Use `$` or `>` as visual indicators
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"
自山口至汤口,山之麓也,登山之径于是始。汤泉之流,自紫石峰六百仞县布,其下有香泉溪。
excerpt: "钱谦益《游黄山记》上篇,包含序、记之一与记之二。"
category: "古籍游记"
published: true
pinned: false
tags:
- design
- terminal
- ui
- 钱谦益
- 黄山
- 游记
- 长文测试
- id: 5
pid: 5
title: "Loco.rs Backend Framework"
title: "游黄山记(中)"
slug: "loco-rs-framework"
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
- **Background Jobs**: Built-in job processing
- **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"
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。"
category: "古籍游记"
published: true
pinned: false
tags:
- rust
- loco-rs
- backend
- api
- 钱谦益
- 黄山
- 游记
- 长文测试

View File

@@ -1,59 +1,59 @@
- id: 1
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: "星际穿越"
title: "《漫长的季节》"
review_type: "movie"
rating: 5
review_date: "2024-02-14"
status: "completed"
description: "诺兰神作,五维空间和黑洞的视觉奇观"
tags: ["科幻", "IMAX", "诺兰"]
cover: "🎬"
review_date: "2024-03-20"
status: "published"
description: "极有质感的中文悬疑剧,人物命运与时代氛围都很扎实。"
tags: ["国产剧", "悬疑", "年度推荐"]
cover: "/review-covers/the-long-season.svg"
- id: 6
title: "博德之门3"
- id: 2
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"
rating: 5
review_date: "2024-04-01"
status: "in-progress"
description: "CRPG的文艺复兴骰子决定命运"
tags: ["PC", "CRPG", "多人"]
cover: "🎮"
review_date: "2024-08-25"
status: "published"
description: "美术和演出都很强,战斗手感也足够扎实,是非常好的中文游戏样本。"
tags: ["国产游戏", "动作", "神话"]
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"

View 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: "InitCoolGitHub 用户名 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

View File

@@ -1,3 +0,0 @@
<html><body>
not found :-(
</body></html>

View File

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

View File

@@ -1,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>

View File

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

View File

@@ -1,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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 %}

View File

@@ -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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

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

View File

@@ -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
```

View 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 了。

View 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)

View 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)
```

View 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

View 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