Compare commits
5 Commits
178434d63e
...
checkpoint
| Author | SHA1 | Date | |
|---|---|---|---|
| 313f174fbc | |||
| a9a05aa105 | |||
| 99b308e800 | |||
| 92a85eef20 | |||
| 84f82c2a7e |
183
.gitea/workflows/backend-docker.yml
Normal file
183
.gitea/workflows/backend-docker.yml
Normal file
@@ -0,0 +1,183 @@
|
||||
name: docker-images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- backend/**
|
||||
- frontend/**
|
||||
- admin/**
|
||||
- deploy/docker/**
|
||||
- .gitea/workflows/backend-docker.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- component: backend
|
||||
dockerfile: backend/Dockerfile
|
||||
context: backend
|
||||
default_image_name: termi-astro-backend
|
||||
- component: frontend
|
||||
dockerfile: frontend/Dockerfile
|
||||
context: frontend
|
||||
default_image_name: termi-astro-frontend
|
||||
- component: admin
|
||||
dockerfile: admin/Dockerfile
|
||||
context: admin
|
||||
default_image_name: termi-astro-admin
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
env:
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
DEFAULT_IMAGE_NAME: ${{ matrix.default_image_name }}
|
||||
VAR_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
||||
VAR_IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE }}
|
||||
VAR_IMAGE_NAME: ${{ vars.IMAGE_NAME }}
|
||||
VAR_BACKEND_IMAGE_NAME: ${{ vars.BACKEND_IMAGE_NAME }}
|
||||
VAR_FRONTEND_IMAGE_NAME: ${{ vars.FRONTEND_IMAGE_NAME }}
|
||||
VAR_ADMIN_IMAGE_NAME: ${{ vars.ADMIN_IMAGE_NAME }}
|
||||
VAR_FRONTEND_PUBLIC_API_BASE_URL: ${{ vars.FRONTEND_PUBLIC_API_BASE_URL }}
|
||||
VAR_ADMIN_VITE_API_BASE: ${{ vars.ADMIN_VITE_API_BASE }}
|
||||
VAR_ADMIN_VITE_FRONTEND_BASE_URL: ${{ vars.ADMIN_VITE_FRONTEND_BASE_URL }}
|
||||
VAR_ADMIN_VITE_BASENAME: ${{ vars.ADMIN_VITE_BASENAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_HOST="${VAR_REGISTRY_HOST:-${GITEA_SERVER_URL#https://}}"
|
||||
if [ -z "${REGISTRY_HOST}" ]; then
|
||||
REGISTRY_HOST="git.init.cool"
|
||||
fi
|
||||
|
||||
REPO_OWNER="${GITHUB_REPOSITORY_OWNER:-${GITEA_REPOSITORY_OWNER:-cool}}"
|
||||
IMAGE_NAMESPACE="${VAR_IMAGE_NAMESPACE:-${REPO_OWNER}}"
|
||||
|
||||
case "${COMPONENT}" in
|
||||
backend)
|
||||
IMAGE_NAME="${VAR_BACKEND_IMAGE_NAME:-${VAR_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}}"
|
||||
;;
|
||||
frontend)
|
||||
IMAGE_NAME="${VAR_FRONTEND_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}"
|
||||
;;
|
||||
admin)
|
||||
IMAGE_NAME="${VAR_ADMIN_IMAGE_NAME:-${DEFAULT_IMAGE_NAME}}"
|
||||
;;
|
||||
*)
|
||||
IMAGE_NAME="${DEFAULT_IMAGE_NAME}"
|
||||
;;
|
||||
esac
|
||||
|
||||
REF_NAME="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-main}}"
|
||||
SAFE_REF="$(echo "${REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g')"
|
||||
|
||||
COMMIT_SHA="${GITHUB_SHA:-${GITEA_SHA:-dev}}"
|
||||
SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)"
|
||||
|
||||
IMAGE_BASE="${REGISTRY_HOST}/${IMAGE_NAMESPACE}/${IMAGE_NAME}"
|
||||
|
||||
FRONTEND_PUBLIC_API_BASE_URL="${VAR_FRONTEND_PUBLIC_API_BASE_URL:-http://localhost:5150/api}"
|
||||
ADMIN_VITE_API_BASE="${VAR_ADMIN_VITE_API_BASE:-http://localhost:5150}"
|
||||
ADMIN_VITE_FRONTEND_BASE_URL="${VAR_ADMIN_VITE_FRONTEND_BASE_URL:-http://localhost:4321}"
|
||||
ADMIN_VITE_BASENAME="${VAR_ADMIN_VITE_BASENAME:-}"
|
||||
|
||||
{
|
||||
echo "registry_host=${REGISTRY_HOST}"
|
||||
echo "image_base=${IMAGE_BASE}"
|
||||
echo "tag_latest=latest"
|
||||
echo "tag_branch=${SAFE_REF}"
|
||||
echo "tag_sha=${SHORT_SHA}"
|
||||
echo "frontend_public_api_base_url=${FRONTEND_PUBLIC_API_BASE_URL}"
|
||||
echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}"
|
||||
echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}"
|
||||
echo "admin_vite_basename=${ADMIN_VITE_BASENAME}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login registry
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY_HOST: ${{ steps.meta.outputs.registry_host }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
|
||||
echo "Missing secrets: REGISTRY_USERNAME / REGISTRY_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY_HOST}" --username "${REGISTRY_USER}" --password-stdin
|
||||
|
||||
- name: Build image
|
||||
shell: bash
|
||||
env:
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
DOCKERFILE: ${{ matrix.dockerfile }}
|
||||
CONTEXT_DIR: ${{ matrix.context }}
|
||||
IMAGE_BASE: ${{ steps.meta.outputs.image_base }}
|
||||
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
|
||||
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
|
||||
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
|
||||
FRONTEND_PUBLIC_API_BASE_URL: ${{ steps.meta.outputs.frontend_public_api_base_url }}
|
||||
ADMIN_VITE_API_BASE: ${{ steps.meta.outputs.admin_vite_api_base }}
|
||||
ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }}
|
||||
ADMIN_VITE_BASENAME: ${{ steps.meta.outputs.admin_vite_basename }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_ARGS=()
|
||||
if [ "${COMPONENT}" = "frontend" ]; then
|
||||
BUILD_ARGS+=(--build-arg "PUBLIC_API_BASE_URL=${FRONTEND_PUBLIC_API_BASE_URL}")
|
||||
fi
|
||||
|
||||
if [ "${COMPONENT}" = "admin" ]; then
|
||||
BUILD_ARGS+=(--build-arg "VITE_API_BASE=${ADMIN_VITE_API_BASE}")
|
||||
BUILD_ARGS+=(--build-arg "VITE_FRONTEND_BASE_URL=${ADMIN_VITE_FRONTEND_BASE_URL}")
|
||||
BUILD_ARGS+=(--build-arg "VITE_ADMIN_BASENAME=${ADMIN_VITE_BASENAME}")
|
||||
fi
|
||||
|
||||
docker build \
|
||||
--file "${DOCKERFILE}" \
|
||||
"${BUILD_ARGS[@]}" \
|
||||
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
|
||||
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
|
||||
--tag "${IMAGE_BASE}:${TAG_SHA}" \
|
||||
"${CONTEXT_DIR}"
|
||||
|
||||
- name: Push image
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_BASE: ${{ steps.meta.outputs.image_base }}
|
||||
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
|
||||
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
|
||||
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker push "${IMAGE_BASE}:${TAG_LATEST}"
|
||||
docker push "${IMAGE_BASE}:${TAG_BRANCH}"
|
||||
docker push "${IMAGE_BASE}:${TAG_SHA}"
|
||||
|
||||
- name: Output image tags
|
||||
shell: bash
|
||||
env:
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
IMAGE_BASE: ${{ steps.meta.outputs.image_base }}
|
||||
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
|
||||
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
|
||||
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
|
||||
run: |
|
||||
echo "[${COMPONENT}] pushed tags:"
|
||||
echo "- ${IMAGE_BASE}:${TAG_LATEST}"
|
||||
echo "- ${IMAGE_BASE}:${TAG_BRANCH}"
|
||||
echo "- ${IMAGE_BASE}:${TAG_SHA}"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -13,6 +13,16 @@ 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
|
||||
|
||||
132
README.md
132
README.md
@@ -8,53 +8,57 @@ Monorepo for the Termi blog system.
|
||||
.
|
||||
├─ admin/ # React + shadcn admin workspace
|
||||
├─ frontend/ # Astro blog frontend
|
||||
├─ backend/ # Loco.rs backend APIs and legacy Tera admin
|
||||
├─ backend/ # Loco.rs backend APIs
|
||||
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
|
||||
├─ deploy/ # Deployment manifests (docker compose/env examples)
|
||||
├─ .codex/ # Codex workspace config
|
||||
└─ .vscode/ # Editor workspace config
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Frontend + backend + MCP:
|
||||
|
||||
```powershell
|
||||
.\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
|
||||
```
|
||||
|
||||
Only admin:
|
||||
|
||||
```powershell
|
||||
.\dev.ps1 -AdminOnly
|
||||
```
|
||||
|
||||
Only MCP:
|
||||
|
||||
```powershell
|
||||
.\dev.ps1 -McpOnly
|
||||
```
|
||||
|
||||
Direct scripts:
|
||||
Legacy aliases are still available and now just forward to `dev.ps1`:
|
||||
|
||||
```powershell
|
||||
.\start-frontend.ps1
|
||||
@@ -67,16 +71,16 @@ Direct scripts:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Admin
|
||||
|
||||
```powershell
|
||||
cd admin
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Backend
|
||||
@@ -87,10 +91,72 @@ $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
|
||||
.\start-mcp.ps1
|
||||
.\dev.ps1 -Only mcp
|
||||
```
|
||||
|
||||
Default MCP endpoint:
|
||||
|
||||
5
admin/.dockerignore
Normal file
5
admin/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
31
admin/Dockerfile
Normal file
31
admin/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
ARG VITE_API_BASE=http://localhost:5150
|
||||
ARG VITE_FRONTEND_BASE_URL=http://localhost:4321
|
||||
ARG VITE_ADMIN_BASENAME=
|
||||
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||
ENV VITE_FRONTEND_BASE_URL=${VITE_FRONTEND_BASE_URL}
|
||||
ENV VITE_ADMIN_BASENAME=${VITE_ADMIN_BASENAME}
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY docker-entrypoint.d/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
24
admin/docker-entrypoint.d/40-runtime-config.sh
Normal file
24
admin/docker-entrypoint.d/40-runtime-config.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
RUNTIME_CONFIG_FILE="/usr/share/nginx/html/runtime-config.js"
|
||||
|
||||
escape_js_string() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
API_BASE_URL="${ADMIN_API_BASE_URL:-}"
|
||||
FRONTEND_BASE_URL="${ADMIN_FRONTEND_BASE_URL:-}"
|
||||
ESCAPED_API_BASE_URL="$(escape_js_string "$API_BASE_URL")"
|
||||
ESCAPED_FRONTEND_BASE_URL="$(escape_js_string "$FRONTEND_BASE_URL")"
|
||||
|
||||
cat > "$RUNTIME_CONFIG_FILE" <<EOF
|
||||
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = Object.assign(
|
||||
{},
|
||||
window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {},
|
||||
{
|
||||
apiBaseUrl: "${ESCAPED_API_BASE_URL}",
|
||||
frontendBaseUrl: "${ESCAPED_FRONTEND_BASE_URL}"
|
||||
},
|
||||
)
|
||||
EOF
|
||||
@@ -18,6 +18,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/runtime-config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
63
admin/nginx.conf
Normal file
63
admin/nginx.conf
Normal file
@@ -0,0 +1,63 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
server_tokens off;
|
||||
charset utf-8;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 5;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/xml+rss
|
||||
application/manifest+json
|
||||
image/svg+xml;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
add_header Cache-Control "no-store";
|
||||
return 200 'ok';
|
||||
}
|
||||
|
||||
location = /runtime-config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
92
admin/package-lock.json
generated
92
admin/package-lock.json
generated
@@ -8,12 +8,16 @@
|
||||
"name": "admin",
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
@@ -561,6 +565,29 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -1223,6 +1250,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
|
||||
@@ -1844,6 +1878,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.328",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz",
|
||||
@@ -2710,6 +2753,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz",
|
||||
"integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -2723,6 +2778,37 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor/node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor/node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3083,6 +3169,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
"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",
|
||||
|
||||
2304
admin/pnpm-lock.yaml
generated
Normal file
2304
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
admin/public/runtime-config.js
Normal file
1
admin/public/runtime-config.js
Normal file
@@ -0,0 +1 @@
|
||||
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {}
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
createContext,
|
||||
lazy,
|
||||
Suspense,
|
||||
startTransition,
|
||||
useContext,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
BrowserRouter,
|
||||
@@ -21,9 +24,52 @@ import { Toaster, toast } from 'sonner'
|
||||
import { AppShell } from '@/components/app-shell'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AdminSessionResponse } from '@/lib/types'
|
||||
import { DashboardPage } from '@/pages/dashboard-page'
|
||||
import { LoginPage } from '@/pages/login-page'
|
||||
import { SiteSettingsPage } from '@/pages/site-settings-page'
|
||||
|
||||
const DashboardPage = lazy(async () => {
|
||||
const mod = await import('@/pages/dashboard-page')
|
||||
return { default: mod.DashboardPage }
|
||||
})
|
||||
const AnalyticsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/analytics-page')
|
||||
return { default: mod.AnalyticsPage }
|
||||
})
|
||||
const PostsPage = lazy(async () => {
|
||||
const mod = await import('@/pages/posts-page')
|
||||
return { default: mod.PostsPage }
|
||||
})
|
||||
const 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
|
||||
@@ -52,11 +98,11 @@ function AppLoadingScreen() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-muted-foreground">
|
||||
Termi admin
|
||||
Termi 后台
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Booting control room</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">正在进入管理后台</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Checking the current admin session and preparing the new React workspace.
|
||||
正在检查当前登录状态,并准备新的 React 管理工作台。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,14 +110,34 @@ function AppLoadingScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionGuard() {
|
||||
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 <Outlet />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function PublicOnly() {
|
||||
@@ -86,6 +152,8 @@ function PublicOnly() {
|
||||
return (
|
||||
<LoginPage
|
||||
submitting={submitting}
|
||||
localLoginEnabled={session.local_login_enabled}
|
||||
proxyAuthEnabled={session.proxy_auth_enabled}
|
||||
onLogin={async (payload) => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
@@ -93,12 +161,10 @@ function PublicOnly() {
|
||||
startTransition(() => {
|
||||
setSession(nextSession)
|
||||
})
|
||||
toast.success('Admin session unlocked.')
|
||||
toast.success('后台登录成功。')
|
||||
navigate('/', { replace: true })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to sign in right now.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '当前无法登录后台。')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -115,7 +181,11 @@ function ProtectedLayout() {
|
||||
return (
|
||||
<AppShell
|
||||
username={session.username}
|
||||
email={session.email}
|
||||
authSource={session.auth_source}
|
||||
authProvider={session.auth_provider}
|
||||
loggingOut={loggingOut}
|
||||
canLogout={session.can_logout}
|
||||
onLogout={async () => {
|
||||
try {
|
||||
setLoggingOut(true)
|
||||
@@ -123,12 +193,10 @@ function ProtectedLayout() {
|
||||
startTransition(() => {
|
||||
setSession(nextSession)
|
||||
})
|
||||
toast.success('Admin session closed.')
|
||||
toast.success('已退出后台。')
|
||||
navigate('/login', { replace: true })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to sign out right now.',
|
||||
)
|
||||
toast.error(error instanceof ApiError ? error.message : '当前无法退出后台。')
|
||||
} finally {
|
||||
setLoggingOut(false)
|
||||
}
|
||||
@@ -143,11 +211,110 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicOnly />} />
|
||||
<Route element={<SessionGuard />}>
|
||||
<Route element={<ProtectedLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="/settings" element={<SiteSettingsPage />} />
|
||||
</Route>
|
||||
<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>
|
||||
@@ -158,6 +325,13 @@ export default function App() {
|
||||
const [session, setSession] = useState<AdminSessionResponse>({
|
||||
authenticated: false,
|
||||
username: null,
|
||||
email: null,
|
||||
auth_source: null,
|
||||
auth_provider: null,
|
||||
groups: [],
|
||||
proxy_auth_enabled: false,
|
||||
local_login_enabled: true,
|
||||
can_logout: false,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -169,7 +343,7 @@ export default function App() {
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError ? error.message : 'Unable to reach the backend session API.',
|
||||
error instanceof ApiError ? error.message : '当前无法连接后台会话接口。',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
||||
@@ -1,38 +1,114 @@
|
||||
import { ExternalLink, LayoutDashboard, LogOut, Orbit, Settings, Sparkles } from 'lucide-react'
|
||||
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: 'Overview',
|
||||
description: 'Live operational dashboard',
|
||||
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: 'Site settings',
|
||||
description: 'Brand, profile, and AI config',
|
||||
label: '设置',
|
||||
description: '品牌、资料与 AI 配置',
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
const nextNav = ['Posts editor', 'Comments moderation', 'Friend links queue', 'Review library']
|
||||
|
||||
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 (
|
||||
@@ -44,15 +120,12 @@ export function AppShell({
|
||||
<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 admin
|
||||
Termi 后台
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Control room for the blog system
|
||||
</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">博客系统控制台</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
A separate React workspace for operations, moderation, and AI-related site
|
||||
controls.
|
||||
一个独立的 React 管理工作台,用来处理发布、审核、运营以及站内 AI 配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,28 +177,25 @@ export function AppShell({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Migration queue
|
||||
工作台状态
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Legacy Tera screens that move here next.
|
||||
核心后台流程统一运行在当前独立管理端。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">phase 1</Badge>
|
||||
<Badge variant="success">运行中</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{nextNav.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center justify-between rounded-2xl border border-border/60 bg-background/60 px-4 py-3"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{item}</span>
|
||||
<Badge variant="secondary">next</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>
|
||||
@@ -138,26 +208,58 @@ export function AppShell({
|
||||
<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" />
|
||||
New admin workspace
|
||||
新版管理工作台
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Signed in as {username ?? 'admin'}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
React + shadcn/ui foundation
|
||||
<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="http://localhost:4321" target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl('/')} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open site
|
||||
打开前台
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void onLogout()} disabled={loggingOut}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void onLogout()}
|
||||
disabled={loggingOut || !canLogout}
|
||||
title={canLogout ? undefined : '当前会话由前置 SSO / 代理控制'}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{loggingOut ? 'Signing out...' : 'Sign out'}
|
||||
{canLogout ? (loggingOut ? '退出中...' : '退出登录') : 'SSO 受代理保护'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
admin/src/components/form-field.tsx
Normal file
21
admin/src/components/form-field.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{hint ? <p className="text-xs leading-5 text-muted-foreground">{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
admin/src/components/lazy-monaco.tsx
Normal file
72
admin/src/components/lazy-monaco.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
import type { DiffEditorProps, EditorProps } from '@monaco-editor/react'
|
||||
|
||||
const MonacoEditor = lazy(async () => {
|
||||
const mod = await import('@monaco-editor/react')
|
||||
return { default: mod.default }
|
||||
})
|
||||
|
||||
const MonacoDiffEditor = lazy(async () => {
|
||||
const mod = await import('@monaco-editor/react')
|
||||
return { default: mod.DiffEditor }
|
||||
})
|
||||
|
||||
function MonacoLoading({
|
||||
height,
|
||||
width,
|
||||
className,
|
||||
loading,
|
||||
}: {
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
className?: string
|
||||
loading?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ height: height ?? '100%', width: width ?? '100%' }}
|
||||
>
|
||||
{loading ?? (
|
||||
<div className="flex h-full min-h-[280px] items-center justify-center bg-[#111111] text-sm text-slate-400">
|
||||
正在加载编辑器...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LazyEditor(props: EditorProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<MonacoLoading
|
||||
height={props.height}
|
||||
width={props.width}
|
||||
className={props.className}
|
||||
loading={props.loading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MonacoEditor {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function LazyDiffEditor(props: DiffEditorProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<MonacoLoading
|
||||
height={props.height}
|
||||
width={props.width}
|
||||
className={props.className}
|
||||
loading={props.loading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MonacoDiffEditor {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
41
admin/src/components/markdown-preview.tsx
Normal file
41
admin/src/components/markdown-preview.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { useDeferredValue, useMemo } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MarkdownPreviewProps = {
|
||||
markdown: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
export function MarkdownPreview({ markdown, className }: MarkdownPreviewProps) {
|
||||
const deferredMarkdown = useDeferredValue(markdown)
|
||||
const html = useMemo(() => {
|
||||
const rendered = marked.parse(deferredMarkdown || '暂无内容。')
|
||||
return DOMPurify.sanitize(typeof rendered === 'string' ? rendered : '')
|
||||
}, [deferredMarkdown])
|
||||
|
||||
return (
|
||||
<div className={cn('h-full overflow-y-auto bg-[#fcfcfd]', className)}>
|
||||
<article
|
||||
className={cn(
|
||||
'mx-auto max-w-4xl px-8 py-8 text-[15px] leading-8 text-slate-700',
|
||||
'[&_a]:text-blue-600 [&_a]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-slate-300 [&_blockquote]:bg-slate-100/80 [&_blockquote]:px-4 [&_blockquote]:py-3 [&_blockquote]:italic',
|
||||
'[&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em]',
|
||||
'[&_h1]:mt-2 [&_h1]:text-3xl [&_h1]:font-semibold [&_h1]:tracking-tight [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-semibold',
|
||||
'[&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_hr]:my-8 [&_hr]:border-slate-200',
|
||||
'[&_li]:my-1 [&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:my-4 [&_pre]:my-5 [&_pre]:overflow-x-auto [&_pre]:rounded-2xl [&_pre]:bg-slate-950 [&_pre]:p-4 [&_pre]:text-sm [&_pre]:text-slate-100 [&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
||||
'[&_table]:my-6 [&_table]:w-full [&_table]:border-collapse [&_table]:overflow-hidden [&_table]:rounded-2xl [&_table]:border [&_table]:border-slate-200 [&_tbody_tr:nth-child(even)]:bg-slate-50/70 [&_td]:border [&_td]:border-slate-200 [&_td]:px-3 [&_td]:py-2 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-100 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left',
|
||||
'[&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-6',
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
admin/src/components/markdown-workbench.tsx
Normal file
335
admin/src/components/markdown-workbench.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import type { BeforeMount } from '@monaco-editor/react'
|
||||
import { Expand, Minimize2, Sparkles } from 'lucide-react'
|
||||
|
||||
import { LazyDiffEditor, LazyEditor } from '@/components/lazy-monaco'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type MarkdownWorkbenchPanel = 'edit' | 'preview' | 'diff'
|
||||
export type MarkdownWorkbenchMode = 'workspace' | 'polish'
|
||||
|
||||
type MarkdownWorkbenchProps = {
|
||||
value: string
|
||||
originalValue: string
|
||||
diffValue?: string
|
||||
path: string
|
||||
workspaceHeightClassName?: string
|
||||
readOnly?: boolean
|
||||
mode: MarkdownWorkbenchMode
|
||||
visiblePanels: MarkdownWorkbenchPanel[]
|
||||
availablePanels?: MarkdownWorkbenchPanel[]
|
||||
allowPolish?: boolean
|
||||
preview: ReactNode
|
||||
polishPanel?: ReactNode
|
||||
originalLabel?: string
|
||||
modifiedLabel?: string
|
||||
onChange: (value: string) => void
|
||||
onModeChange: (next: MarkdownWorkbenchMode) => void
|
||||
onVisiblePanelsChange: (next: MarkdownWorkbenchPanel[]) => void
|
||||
}
|
||||
|
||||
export const editorTheme = 'termi-vscode'
|
||||
|
||||
const orderedWorkbenchPanels: MarkdownWorkbenchPanel[] = ['edit', 'preview', 'diff']
|
||||
|
||||
function formatPanelLabel(panel: MarkdownWorkbenchPanel) {
|
||||
switch (panel) {
|
||||
case 'preview':
|
||||
return '预览'
|
||||
case 'diff':
|
||||
return '改动对比'
|
||||
case 'edit':
|
||||
default:
|
||||
return '编辑'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVisiblePanels(
|
||||
visiblePanels: MarkdownWorkbenchPanel[],
|
||||
availablePanels: MarkdownWorkbenchPanel[],
|
||||
) {
|
||||
const orderedAvailablePanels = orderedWorkbenchPanels.filter((panel) =>
|
||||
availablePanels.includes(panel),
|
||||
)
|
||||
const nextPanels = orderedAvailablePanels.filter((panel) => visiblePanels.includes(panel))
|
||||
return nextPanels.length ? nextPanels : orderedAvailablePanels.slice(0, 1)
|
||||
}
|
||||
|
||||
export const configureMonaco: BeforeMount = (monaco) => {
|
||||
monaco.editor.defineTheme(editorTheme, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'comment', foreground: '6A9955' },
|
||||
{ token: 'keyword', foreground: 'C586C0' },
|
||||
{ token: 'string', foreground: 'CE9178' },
|
||||
{ token: 'number', foreground: 'B5CEA8' },
|
||||
{ token: 'delimiter', foreground: 'D4D4D4' },
|
||||
{ token: 'type.identifier', foreground: '4EC9B0' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#1e1e1e',
|
||||
'editor.foreground': '#d4d4d4',
|
||||
'editor.lineHighlightBackground': '#2a2d2e',
|
||||
'editor.lineHighlightBorder': '#00000000',
|
||||
'editorCursor.foreground': '#aeafad',
|
||||
'editor.selectionBackground': '#264f78',
|
||||
'editor.inactiveSelectionBackground': '#3a3d41',
|
||||
'editorWhitespace.foreground': '#3b3b3b',
|
||||
'editorIndentGuide.background1': '#404040',
|
||||
'editorIndentGuide.activeBackground1': '#707070',
|
||||
'editorLineNumber.foreground': '#858585',
|
||||
'editorLineNumber.activeForeground': '#c6c6c6',
|
||||
'editorGutter.background': '#1e1e1e',
|
||||
'editorOverviewRuler.border': '#00000000',
|
||||
'diffEditor.insertedTextBackground': '#9ccc2c33',
|
||||
'diffEditor.removedTextBackground': '#ff6b6b2d',
|
||||
'diffEditor.insertedLineBackground': '#9ccc2c18',
|
||||
'diffEditor.removedLineBackground': '#ff6b6b18',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const sharedOptions = {
|
||||
automaticLayout: true,
|
||||
fontFamily:
|
||||
'"JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, SFMono-Regular, monospace',
|
||||
fontLigatures: true,
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
minimap: { enabled: false },
|
||||
padding: { top: 16, bottom: 16 },
|
||||
renderWhitespace: 'selection' as const,
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on' as const,
|
||||
}
|
||||
|
||||
export function MarkdownWorkbench({
|
||||
value,
|
||||
originalValue,
|
||||
diffValue,
|
||||
path,
|
||||
workspaceHeightClassName = 'h-[560px]',
|
||||
readOnly = false,
|
||||
mode,
|
||||
visiblePanels,
|
||||
availablePanels = ['edit', 'preview', 'diff'],
|
||||
allowPolish,
|
||||
preview,
|
||||
polishPanel,
|
||||
originalLabel = '基线版本',
|
||||
modifiedLabel = '目标版本',
|
||||
onChange,
|
||||
onModeChange,
|
||||
onVisiblePanelsChange,
|
||||
}: MarkdownWorkbenchProps) {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const editorHeight = fullscreen ? 'h-[calc(100dvh-82px)]' : workspaceHeightClassName
|
||||
const diffContent = diffValue ?? value
|
||||
const polishEnabled = allowPolish ?? Boolean(polishPanel)
|
||||
const workspacePanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||
const renderDiffSideBySide = workspacePanels.length < 3 || fullscreen
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [fullscreen])
|
||||
|
||||
const togglePanel = (panel: MarkdownWorkbenchPanel) => {
|
||||
const currentPanels = resolveVisiblePanels(visiblePanels, availablePanels)
|
||||
const nextPanels = currentPanels.includes(panel)
|
||||
? currentPanels.filter((item) => item !== panel)
|
||||
: orderedWorkbenchPanels.filter(
|
||||
(item) => availablePanels.includes(item) && (currentPanels.includes(item) || item === panel),
|
||||
)
|
||||
|
||||
onVisiblePanelsChange(nextPanels.length ? nextPanels : availablePanels.slice(0, 1))
|
||||
|
||||
if (mode !== 'workspace') {
|
||||
onModeChange('workspace')
|
||||
}
|
||||
}
|
||||
|
||||
const workbench = (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e] shadow-[0_24px_60px_rgba(15,23,42,0.28)]',
|
||||
fullscreen && 'relative h-[100dvh] rounded-none border-0 shadow-none',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 border-b border-slate-800 bg-[#181818] px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||
</div>
|
||||
<p className="font-mono text-xs text-slate-400">{path}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{availablePanels.map((panel) => {
|
||||
const active = mode === 'workspace' && workspacePanels.includes(panel)
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={panel}
|
||||
variant={active ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => togglePanel(panel)}
|
||||
className={
|
||||
active
|
||||
? 'bg-[#0e639c] text-white shadow-none hover:bg-[#1177bb]'
|
||||
: 'border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white'
|
||||
}
|
||||
>
|
||||
{formatPanelLabel(panel)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{polishEnabled ? (
|
||||
<Button
|
||||
variant={mode === 'polish' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onModeChange(mode === 'polish' ? 'workspace' : 'polish')}
|
||||
className={
|
||||
mode === 'polish'
|
||||
? 'bg-[#0e639c] text-white shadow-none hover:bg-[#1177bb]'
|
||||
: 'border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white'
|
||||
}
|
||||
>
|
||||
<Sparkles className="mr-1 h-4 w-4" />
|
||||
AI 润色
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFullscreen((current) => !current)}
|
||||
className="border-slate-700 bg-[#202020] text-slate-200 hover:bg-[#292929] hover:text-white"
|
||||
>
|
||||
{fullscreen ? (
|
||||
<>
|
||||
<Minimize2 className="mr-1 h-4 w-4" />
|
||||
退出全屏
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Expand className="mr-1 h-4 w-4" />
|
||||
全屏
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={editorHeight}>
|
||||
{mode === 'polish' ? (
|
||||
<div className="h-full bg-[#111111]">{polishPanel}</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col bg-slate-900 xl:flex-row">
|
||||
{workspacePanels.map((panel, index) => (
|
||||
<section
|
||||
key={panel}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col bg-[#1b1b1b]',
|
||||
index < workspacePanels.length - 1 &&
|
||||
'border-b border-slate-800 xl:border-b-0 xl:border-r',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
|
||||
<span>{formatPanelLabel(panel)}</span>
|
||||
{panel === 'diff' ? (
|
||||
<span>
|
||||
{originalLabel} / {modifiedLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span>{path}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panel === 'edit' ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<LazyEditor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
path={path}
|
||||
value={value}
|
||||
keepCurrentModel
|
||||
theme={editorTheme}
|
||||
beforeMount={configureMonaco}
|
||||
options={{
|
||||
...sharedOptions,
|
||||
readOnly,
|
||||
stickyScroll: { enabled: true },
|
||||
}}
|
||||
onChange={(next) => onChange(next ?? '')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{panel === 'preview' ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#141414]">{preview}</div>
|
||||
) : null}
|
||||
|
||||
{panel === 'diff' ? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<LazyDiffEditor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
original={originalValue}
|
||||
modified={diffContent}
|
||||
originalModelPath={`${path}#saved`}
|
||||
modifiedModelPath={`${path}#draft`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
theme={editorTheme}
|
||||
beforeMount={configureMonaco}
|
||||
options={{
|
||||
...sharedOptions,
|
||||
originalEditable: false,
|
||||
readOnly: true,
|
||||
renderSideBySide: renderDiffSideBySide,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!fullscreen) {
|
||||
return workbench
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return workbench
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[900] bg-slate-950/92 backdrop-blur-md" />
|
||||
<div className="fixed inset-0 z-[1000]">{workbench}</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
502
admin/src/components/ui/select.tsx
Normal file
502
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import * as React from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NativeSelectProps = React.ComponentProps<'select'>
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
label: React.ReactNode
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
type MenuPlacement = 'top' | 'bottom'
|
||||
|
||||
function normalizeValue(value: NativeSelectProps['value'] | NativeSelectProps['defaultValue']) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] == null ? '' : String(value[0])
|
||||
}
|
||||
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
function extractOptions(children: React.ReactNode) {
|
||||
const options: SelectOption[] = []
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child) || child.type !== 'option') {
|
||||
return
|
||||
}
|
||||
|
||||
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement> & {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: normalizeValue(props.value),
|
||||
label: props.children,
|
||||
disabled: Boolean(props.disabled),
|
||||
})
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function getFirstEnabledIndex(options: SelectOption[]) {
|
||||
return options.findIndex((option) => !option.disabled)
|
||||
}
|
||||
|
||||
function getLastEnabledIndex(options: SelectOption[]) {
|
||||
for (let index = options.length - 1; index >= 0; index -= 1) {
|
||||
if (!options[index]?.disabled) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function getNextEnabledIndex(options: SelectOption[], currentIndex: number, direction: 1 | -1) {
|
||||
if (options.length === 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let index = currentIndex
|
||||
|
||||
for (let step = 0; step < options.length; step += 1) {
|
||||
index = (index + direction + options.length) % options.length
|
||||
if (!options[index]?.disabled) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
defaultValue,
|
||||
disabled = false,
|
||||
id,
|
||||
onBlur,
|
||||
onClick,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
value,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const options = React.useMemo(() => extractOptions(children), [children])
|
||||
const isControlled = value !== undefined
|
||||
const initialValue = React.useMemo(() => {
|
||||
if (defaultValue !== undefined) {
|
||||
return normalizeValue(defaultValue)
|
||||
}
|
||||
|
||||
return options[0]?.value ?? ''
|
||||
}, [defaultValue, options])
|
||||
|
||||
const [internalValue, setInternalValue] = React.useState(initialValue)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(-1)
|
||||
const [menuPlacement, setMenuPlacement] = React.useState<MenuPlacement>('bottom')
|
||||
const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties | null>(null)
|
||||
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
const nativeSelectRef = React.useRef<HTMLSelectElement>(null)
|
||||
const menuRef = React.useRef<HTMLDivElement>(null)
|
||||
const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
|
||||
const menuId = React.useId()
|
||||
|
||||
const currentValue = isControlled ? normalizeValue(value) : internalValue
|
||||
const selectedIndex = options.findIndex((option) => option.value === currentValue)
|
||||
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : options[0] ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isControlled && options.length > 0 && !options.some((option) => option.value === internalValue)) {
|
||||
setInternalValue(options[0]?.value ?? '')
|
||||
}
|
||||
}, [internalValue, isControlled, options])
|
||||
|
||||
const updateMenuPosition = React.useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const viewportPadding = 12
|
||||
const gutter = 6
|
||||
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
|
||||
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
|
||||
const spaceAbove = rect.top - viewportPadding
|
||||
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
|
||||
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
|
||||
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2)
|
||||
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
|
||||
|
||||
setMenuPlacement(openToTop ? 'top' : 'bottom')
|
||||
setMenuStyle(
|
||||
openToTop
|
||||
? {
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
bottom: window.innerHeight - rect.top + gutter,
|
||||
}
|
||||
: {
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
top: rect.bottom + gutter,
|
||||
},
|
||||
)
|
||||
}, [options.length])
|
||||
|
||||
const setOpenWithHighlight = React.useCallback(
|
||||
(nextOpen: boolean, preferredIndex?: number) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextOpen) {
|
||||
const fallbackIndex =
|
||||
preferredIndex ??
|
||||
(selectedIndex >= 0 && !options[selectedIndex]?.disabled
|
||||
? selectedIndex
|
||||
: getFirstEnabledIndex(options))
|
||||
|
||||
setHighlightedIndex(fallbackIndex)
|
||||
updateMenuPosition()
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
},
|
||||
[disabled, options, selectedIndex, updateMenuPosition],
|
||||
)
|
||||
|
||||
const commitValue = React.useCallback(
|
||||
(nextIndex: number) => {
|
||||
const option = options[nextIndex]
|
||||
const nativeSelect = nativeSelectRef.current
|
||||
|
||||
if (!option || option.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isControlled) {
|
||||
setInternalValue(option.value)
|
||||
}
|
||||
|
||||
if (nativeSelect && currentValue !== option.value) {
|
||||
nativeSelect.value = option.value
|
||||
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
window.requestAnimationFrame(() => {
|
||||
triggerRef.current?.focus()
|
||||
})
|
||||
},
|
||||
[currentValue, isControlled, options],
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement | HTMLDivElement>) => {
|
||||
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLSelectElement>)
|
||||
if (event.defaultPrevented || disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
const nextIndex =
|
||||
selectedIndex >= 0
|
||||
? getNextEnabledIndex(options, selectedIndex, 1)
|
||||
: getFirstEnabledIndex(options)
|
||||
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getFirstEnabledIndex(options))
|
||||
return
|
||||
}
|
||||
|
||||
setHighlightedIndex((current) => getNextEnabledIndex(options, current, 1))
|
||||
return
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
const nextIndex =
|
||||
selectedIndex >= 0
|
||||
? getNextEnabledIndex(options, selectedIndex, -1)
|
||||
: getLastEnabledIndex(options)
|
||||
setOpenWithHighlight(true, nextIndex >= 0 ? nextIndex : getLastEnabledIndex(options))
|
||||
return
|
||||
}
|
||||
|
||||
setHighlightedIndex((current) => getNextEnabledIndex(options, current, -1))
|
||||
return
|
||||
}
|
||||
case 'Home': {
|
||||
event.preventDefault()
|
||||
const firstIndex = getFirstEnabledIndex(options)
|
||||
if (!open) {
|
||||
setOpenWithHighlight(true, firstIndex)
|
||||
return
|
||||
}
|
||||
setHighlightedIndex(firstIndex)
|
||||
return
|
||||
}
|
||||
case 'End': {
|
||||
event.preventDefault()
|
||||
const lastIndex = getLastEnabledIndex(options)
|
||||
if (!open) {
|
||||
setOpenWithHighlight(true, lastIndex)
|
||||
return
|
||||
}
|
||||
setHighlightedIndex(lastIndex)
|
||||
return
|
||||
}
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
setOpenWithHighlight(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (highlightedIndex >= 0) {
|
||||
commitValue(highlightedIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'Escape': {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
case 'Tab': {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
},
|
||||
[commitValue, disabled, highlightedIndex, onKeyDown, open, options, selectedIndex, setOpenWithHighlight],
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
}, [open, updateMenuPosition])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (wrapperRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleWindowChange = () => updateMenuPosition()
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
window.addEventListener('resize', handleWindowChange)
|
||||
window.addEventListener('scroll', handleWindowChange, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
window.removeEventListener('resize', handleWindowChange)
|
||||
window.removeEventListener('scroll', handleWindowChange, true)
|
||||
}
|
||||
}, [open, updateMenuPosition])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || highlightedIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlightedIndex, open])
|
||||
|
||||
const triggerClasses = cn(
|
||||
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/20 data-[state=open]:bg-card/95 data-[state=open]:shadow-[0_16px_36px_rgb(15_23_42_/_0.10)]',
|
||||
className,
|
||||
)
|
||||
|
||||
const menu = open && menuStyle
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
aria-orientation="vertical"
|
||||
className={cn(
|
||||
'custom-select-popover fixed z-[80] overflow-hidden rounded-[20px] border border-border/80 bg-[color:rgb(255_255_255_/_0.96)] p-2 text-popover-foreground shadow-[0_18px_46px_rgb(15_23_42_/_0.12)] backdrop-blur-xl will-change-transform dark:bg-card/96',
|
||||
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
|
||||
)}
|
||||
id={menuId}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="listbox"
|
||||
style={menuStyle}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="max-h-full overflow-y-auto pr-0.5">
|
||||
{options.map((option, index) => {
|
||||
const selected = option.value === currentValue
|
||||
const highlighted = index === highlightedIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${option.value}-${index}`}
|
||||
ref={(node) => {
|
||||
optionRefs.current[index] = node
|
||||
}}
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'relative flex min-h-10.5 w-full items-center justify-between gap-3 overflow-hidden rounded-[16px] border px-4 py-2.5 text-left text-sm transition-[background-color,border-color,color,box-shadow]',
|
||||
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
|
||||
selected
|
||||
? 'border-primary/15 bg-primary/[0.045] text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.55)]'
|
||||
: highlighted
|
||||
? 'border-border/60 bg-muted/70 text-foreground'
|
||||
: 'border-transparent text-foreground/80 hover:border-border/45 hover:bg-muted/55 hover:text-foreground',
|
||||
)}
|
||||
disabled={option.disabled}
|
||||
onClick={() => commitValue(index)}
|
||||
onMouseEnter={() => {
|
||||
if (!option.disabled) {
|
||||
setHighlightedIndex(index)
|
||||
}
|
||||
}}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute left-1.5 top-1/2 h-5 w-1 -translate-y-1/2 rounded-full transition-all',
|
||||
selected ? 'bg-primary/70 opacity-100' : 'bg-transparent opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate pr-2',
|
||||
selected
|
||||
? 'font-semibold text-foreground'
|
||||
: highlighted
|
||||
? 'font-medium text-foreground'
|
||||
: 'font-medium',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 transition-[opacity,transform,color]',
|
||||
selected ? 'translate-x-0 opacity-100 text-primary/90' : 'translate-x-1 opacity-0 text-transparent',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<select
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-0 w-0 opacity-0"
|
||||
defaultValue={defaultValue}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
ref={(node) => {
|
||||
nativeSelectRef.current = node
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(node)
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = node
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
value={isControlled ? currentValue : internalValue}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
|
||||
<button
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={triggerClasses}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
disabled={disabled}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onClick?.(event as unknown as React.MouseEvent<HTMLSelectElement>)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerRef.current?.focus()
|
||||
setOpenWithHighlight(!open)
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event as unknown as React.FocusEvent<HTMLSelectElement>)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={triggerRef}
|
||||
role="combobox"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors',
|
||||
open && 'bg-muted text-foreground',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{menu}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export { Select }
|
||||
@@ -116,6 +116,23 @@ a {
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@keyframes custom-select-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px) scale(0.985);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-popover {
|
||||
animation: custom-select-pop 0.1s ease-out;
|
||||
}
|
||||
|
||||
207
admin/src/lib/admin-format.ts
Normal file
207
admin/src/lib/admin-format.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '暂无'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatPostType(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'article':
|
||||
return '文章'
|
||||
case 'note':
|
||||
return '笔记'
|
||||
case 'page':
|
||||
return '页面'
|
||||
case 'snippet':
|
||||
return '片段'
|
||||
default:
|
||||
return value || '文章'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCommentScope(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'paragraph':
|
||||
return '段落'
|
||||
case 'article':
|
||||
return '全文'
|
||||
default:
|
||||
return value || '全文'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPostStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'draft':
|
||||
return '草稿'
|
||||
case 'published':
|
||||
return '已发布'
|
||||
case 'scheduled':
|
||||
return '定时发布'
|
||||
case 'expired':
|
||||
return '已下线'
|
||||
case 'offline':
|
||||
return '离线'
|
||||
default:
|
||||
return value || '已发布'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPostVisibility(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'unlisted':
|
||||
return '不公开'
|
||||
case 'private':
|
||||
return '私有'
|
||||
case 'public':
|
||||
return '公开'
|
||||
default:
|
||||
return value || '公开'
|
||||
}
|
||||
}
|
||||
|
||||
function matchBrowserVersion(userAgent: string, marker: RegExp) {
|
||||
const matched = userAgent.match(marker)
|
||||
return matched?.[1] ?? null
|
||||
}
|
||||
|
||||
export function formatBrowserName(userAgent: string | null | undefined) {
|
||||
if (!userAgent) {
|
||||
return '未知浏览器'
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase()
|
||||
|
||||
if (ua.includes('edg/')) {
|
||||
const version = matchBrowserVersion(userAgent, /edg\/([\d.]+)/i)
|
||||
return version ? `Edge ${version}` : 'Edge'
|
||||
}
|
||||
|
||||
if (ua.includes('opr/') || ua.includes('opera')) {
|
||||
const version = matchBrowserVersion(userAgent, /(?:opr|opera)\/([\d.]+)/i)
|
||||
return version ? `Opera ${version}` : 'Opera'
|
||||
}
|
||||
|
||||
if (ua.includes('firefox/')) {
|
||||
const version = matchBrowserVersion(userAgent, /firefox\/([\d.]+)/i)
|
||||
return version ? `Firefox ${version}` : 'Firefox'
|
||||
}
|
||||
|
||||
if (ua.includes('chrome/') && !ua.includes('chromium/')) {
|
||||
const version = matchBrowserVersion(userAgent, /chrome\/([\d.]+)/i)
|
||||
return version ? `Chrome ${version}` : 'Chrome'
|
||||
}
|
||||
|
||||
if (ua.includes('safari/') && !ua.includes('chrome/')) {
|
||||
const version = matchBrowserVersion(userAgent, /version\/([\d.]+)/i)
|
||||
return version ? `Safari ${version}` : 'Safari'
|
||||
}
|
||||
|
||||
return '其他浏览器'
|
||||
}
|
||||
|
||||
export function formatFriendLinkStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'approved':
|
||||
return '已通过'
|
||||
case 'rejected':
|
||||
return '已拒绝'
|
||||
case 'pending':
|
||||
return '待审核'
|
||||
default:
|
||||
return value || '待审核'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReviewType(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'book':
|
||||
return '图书'
|
||||
case 'movie':
|
||||
return '电影'
|
||||
case 'game':
|
||||
return '游戏'
|
||||
case 'anime':
|
||||
return '动画'
|
||||
case 'music':
|
||||
return '音乐'
|
||||
default:
|
||||
return value || '未分类'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReviewStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'published':
|
||||
return '已发布'
|
||||
case 'draft':
|
||||
return '草稿'
|
||||
case 'archived':
|
||||
return '已归档'
|
||||
case 'completed':
|
||||
return '已完成'
|
||||
case 'in-progress':
|
||||
return '进行中'
|
||||
default:
|
||||
return value || '未知状态'
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyToNull(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
export function linesToList(value: string) {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function csvToList(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function postTagsToList(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function reviewTagsToList(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
return csvToList(value)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -1,12 +1,84 @@
|
||||
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 API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
|
||||
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
|
||||
@@ -22,7 +94,7 @@ async function readErrorMessage(response: Response) {
|
||||
const text = await response.text().catch(() => '')
|
||||
|
||||
if (!text) {
|
||||
return `Request failed with status ${response.status}.`
|
||||
return `请求失败,状态码 ${response.status}。`
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -33,6 +105,30 @@ async function readErrorMessage(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -42,6 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
})
|
||||
|
||||
@@ -73,7 +170,76 @@ export const adminApi = {
|
||||
request<AdminSessionResponse>('/api/admin/session', {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listAuditLogs: (query?: { action?: string; targetType?: string; limit?: number }) =>
|
||||
request<AuditLogRecord[]>(
|
||||
appendQueryParams('/api/admin/audit-logs', {
|
||||
action: query?.action,
|
||||
target_type: query?.targetType,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
listPostRevisions: (query?: { slug?: string; limit?: number }) =>
|
||||
request<PostRevisionRecord[]>(
|
||||
appendQueryParams('/api/admin/post-revisions', {
|
||||
slug: query?.slug,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
getPostRevision: (id: number) => request<PostRevisionDetail>(`/api/admin/post-revisions/${id}`),
|
||||
restorePostRevision: (id: number, mode: 'full' | 'markdown' | 'metadata' = 'full') =>
|
||||
request<RestoreRevisionResponse>(`/api/admin/post-revisions/${id}/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
listSubscriptions: async () =>
|
||||
(await request<SubscriptionListResponse>('/api/admin/subscriptions')).subscriptions,
|
||||
createSubscription: (payload: SubscriptionPayload) =>
|
||||
request<SubscriptionRecord>('/api/admin/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: payload.channelType,
|
||||
target: payload.target,
|
||||
displayName: payload.displayName,
|
||||
status: payload.status,
|
||||
filters: payload.filters,
|
||||
metadata: payload.metadata,
|
||||
secret: payload.secret,
|
||||
notes: payload.notes,
|
||||
}),
|
||||
}),
|
||||
updateSubscription: (id: number, payload: SubscriptionUpdatePayload) =>
|
||||
request<SubscriptionRecord>(`/api/admin/subscriptions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
channelType: payload.channelType,
|
||||
target: payload.target,
|
||||
displayName: payload.displayName,
|
||||
status: payload.status,
|
||||
filters: payload.filters,
|
||||
metadata: payload.metadata,
|
||||
secret: payload.secret,
|
||||
notes: payload.notes,
|
||||
}),
|
||||
}),
|
||||
deleteSubscription: (id: number) =>
|
||||
request<void>(`/api/admin/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
testSubscription: (id: number) =>
|
||||
request<{ queued: boolean; id: number; delivery_id: number }>(`/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', {
|
||||
@@ -84,4 +250,338 @@ export const adminApi = {
|
||||
request<AdminAiReindexResponse>('/api/admin/ai/reindex', {
|
||||
method: 'POST',
|
||||
}),
|
||||
testAiProvider: (provider: {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
api_base: string | null
|
||||
api_key: string | null
|
||||
chat_model: string | null
|
||||
}) =>
|
||||
request<AdminAiProviderTestResponse>('/api/admin/ai/test-provider', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider }),
|
||||
}),
|
||||
testAiImageProvider: (provider: {
|
||||
provider: string
|
||||
api_base: string | null
|
||||
api_key: string | null
|
||||
image_model: string | null
|
||||
}) =>
|
||||
request<AdminAiImageProviderTestResponse>('/api/admin/ai/test-image-provider', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
provider: provider.provider,
|
||||
api_base: provider.api_base,
|
||||
api_key: provider.api_key,
|
||||
image_model: provider.image_model,
|
||||
}),
|
||||
}),
|
||||
uploadReviewCoverImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, file.name)
|
||||
|
||||
return request<AdminImageUploadResponse>('/api/admin/storage/review-cover', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
testR2Storage: () =>
|
||||
request<AdminR2ConnectivityResponse>('/api/admin/storage/r2/test', {
|
||||
method: 'POST',
|
||||
}),
|
||||
listMediaObjects: (query?: { prefix?: string; limit?: number }) =>
|
||||
request<AdminMediaListResponse>(
|
||||
appendQueryParams('/api/admin/storage/media', {
|
||||
prefix: query?.prefix,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
deleteMediaObject: (key: string) =>
|
||||
request<AdminMediaDeleteResponse>(
|
||||
`/api/admin/storage/media?key=${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
),
|
||||
uploadMediaObjects: (files: File[], options?: { prefix?: string }) => {
|
||||
const formData = new FormData()
|
||||
if (options?.prefix) {
|
||||
formData.append('prefix', options.prefix)
|
||||
}
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.name)
|
||||
})
|
||||
|
||||
return request<AdminMediaUploadResponse>('/api/admin/storage/media', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
batchDeleteMediaObjects: (keys: string[]) =>
|
||||
request<AdminMediaBatchDeleteResponse>('/api/admin/storage/media/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keys }),
|
||||
}),
|
||||
replaceMediaObject: (key: string, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('key', key)
|
||||
formData.append('file', file, file.name)
|
||||
|
||||
return request<AdminMediaReplaceResponse>('/api/admin/storage/media/replace', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
generatePostMetadata: (markdown: string) =>
|
||||
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ markdown }),
|
||||
}),
|
||||
polishPostMarkdown: (markdown: string) =>
|
||||
request<AdminPostPolishResponse>('/api/admin/ai/polish-post', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ markdown }),
|
||||
}),
|
||||
polishReviewDescription: (payload: AdminReviewPolishRequest) =>
|
||||
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
review_type: payload.reviewType,
|
||||
rating: payload.rating,
|
||||
review_date: payload.reviewDate,
|
||||
status: payload.status,
|
||||
tags: payload.tags,
|
||||
description: payload.description,
|
||||
}),
|
||||
}),
|
||||
generatePostCoverImage: (payload: AdminPostCoverImageRequest) =>
|
||||
request<AdminPostCoverImageResponse>('/api/admin/ai/post-cover', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
category: payload.category,
|
||||
tags: payload.tags,
|
||||
post_type: payload.postType,
|
||||
slug: payload.slug,
|
||||
markdown: payload.markdown,
|
||||
}),
|
||||
}),
|
||||
listPosts: (query?: PostListQuery) =>
|
||||
request<PostRecord[]>(
|
||||
appendQueryParams('/api/posts', {
|
||||
slug: query?.slug,
|
||||
category: query?.category,
|
||||
tag: query?.tag,
|
||||
search: query?.search,
|
||||
type: query?.postType,
|
||||
pinned: query?.pinned,
|
||||
status: query?.status,
|
||||
visibility: query?.visibility,
|
||||
listed_only: query?.listedOnly,
|
||||
include_private: query?.includePrivate ?? true,
|
||||
include_redirects: query?.includeRedirects ?? true,
|
||||
preview: query?.preview ?? true,
|
||||
}),
|
||||
),
|
||||
getPostBySlug: (slug: string) =>
|
||||
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
|
||||
createPost: (payload: CreatePostPayload) =>
|
||||
request<MarkdownDocumentResponse>('/api/posts/markdown', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
slug: payload.slug,
|
||||
description: payload.description,
|
||||
content: payload.content,
|
||||
category: payload.category,
|
||||
tags: payload.tags,
|
||||
post_type: payload.postType,
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
status: payload.status,
|
||||
visibility: payload.visibility,
|
||||
publish_at: payload.publishAt,
|
||||
unpublish_at: payload.unpublishAt,
|
||||
canonical_url: payload.canonicalUrl,
|
||||
noindex: payload.noindex,
|
||||
og_image: payload.ogImage,
|
||||
redirect_from: payload.redirectFrom,
|
||||
redirect_to: payload.redirectTo,
|
||||
published: payload.published,
|
||||
}),
|
||||
}),
|
||||
updatePost: (id: number, payload: UpdatePostPayload) =>
|
||||
request<PostRecord>(`/api/posts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
slug: payload.slug,
|
||||
description: payload.description,
|
||||
content: payload.content,
|
||||
category: payload.category,
|
||||
tags: payload.tags,
|
||||
post_type: payload.postType,
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
status: payload.status,
|
||||
visibility: payload.visibility,
|
||||
publish_at: payload.publishAt,
|
||||
unpublish_at: payload.unpublishAt,
|
||||
canonical_url: payload.canonicalUrl,
|
||||
noindex: payload.noindex,
|
||||
og_image: payload.ogImage,
|
||||
redirect_from: payload.redirectFrom,
|
||||
redirect_to: payload.redirectTo,
|
||||
}),
|
||||
}),
|
||||
getPostMarkdown: (slug: string) =>
|
||||
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`),
|
||||
importPosts: async (files: File[]) => {
|
||||
const formData = new FormData()
|
||||
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.webkitRelativePath || file.name)
|
||||
})
|
||||
|
||||
return request<MarkdownImportResponse>('/api/posts/markdown/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
updatePostMarkdown: (slug: string, markdown: string) =>
|
||||
request<MarkdownDocumentResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ markdown }),
|
||||
}),
|
||||
deletePost: (slug: string) =>
|
||||
request<MarkdownDeleteResponse>(`/api/posts/slug/${encodeURIComponent(slug)}/markdown`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listComments: (query?: CommentListQuery) =>
|
||||
request<CommentRecord[]>(
|
||||
appendQueryParams('/api/comments', {
|
||||
post_id: query?.postId,
|
||||
post_slug: query?.postSlug,
|
||||
scope: query?.scope,
|
||||
paragraph_key: query?.paragraphKey,
|
||||
approved: query?.approved,
|
||||
}),
|
||||
),
|
||||
updateComment: (id: number, payload: UpdateCommentPayload) =>
|
||||
request<CommentRecord>(`/api/comments/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteComment: (id: number) =>
|
||||
request<void>(`/api/comments/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listCommentBlacklist: () =>
|
||||
request<CommentBlacklistRecord[]>('/api/admin/comments/blacklist'),
|
||||
createCommentBlacklist: (payload: {
|
||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value: string
|
||||
reason?: string | null
|
||||
active?: boolean
|
||||
expires_at?: string | null
|
||||
}) =>
|
||||
request<CommentBlacklistRecord>('/api/admin/comments/blacklist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateCommentBlacklist: (
|
||||
id: number,
|
||||
payload: {
|
||||
reason?: string | null
|
||||
active?: boolean
|
||||
expires_at?: string | null
|
||||
clear_expires_at?: boolean
|
||||
},
|
||||
) =>
|
||||
request<CommentBlacklistRecord>(`/api/admin/comments/blacklist/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteCommentBlacklist: (id: number) =>
|
||||
request<{ deleted: boolean; id: number }>(`/api/admin/comments/blacklist/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
analyzeCommentPersona: (payload: {
|
||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value: string
|
||||
from?: string | null
|
||||
to?: string | null
|
||||
limit?: number
|
||||
}) =>
|
||||
request<CommentPersonaAnalysisResponse>('/api/admin/comments/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listCommentPersonaAnalysisLogs: (query?: {
|
||||
matcher_type?: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value?: string
|
||||
limit?: number
|
||||
}) =>
|
||||
request<CommentPersonaAnalysisLogRecord[]>(
|
||||
appendQueryParams('/api/admin/comments/analyze/logs', {
|
||||
matcher_type: query?.matcher_type,
|
||||
matcher_value: query?.matcher_value,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
listFriendLinks: (query?: FriendLinkListQuery) =>
|
||||
request<FriendLinkRecord[]>(
|
||||
appendQueryParams('/api/friend_links', {
|
||||
status: query?.status,
|
||||
category: query?.category,
|
||||
}),
|
||||
),
|
||||
createFriendLink: (payload: FriendLinkPayload) =>
|
||||
request<FriendLinkRecord>('/api/friend_links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
siteName: payload.siteName,
|
||||
siteUrl: payload.siteUrl,
|
||||
avatarUrl: payload.avatarUrl,
|
||||
description: payload.description,
|
||||
category: payload.category,
|
||||
status: payload.status,
|
||||
}),
|
||||
}),
|
||||
updateFriendLink: (id: number, payload: FriendLinkPayload) =>
|
||||
request<FriendLinkRecord>(`/api/friend_links/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
site_name: payload.siteName,
|
||||
site_url: payload.siteUrl,
|
||||
avatar_url: payload.avatarUrl,
|
||||
description: payload.description,
|
||||
category: payload.category,
|
||||
status: payload.status,
|
||||
}),
|
||||
}),
|
||||
deleteFriendLink: (id: number) =>
|
||||
request<void>(`/api/friend_links/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listReviews: () => request<ReviewRecord[]>('/api/reviews'),
|
||||
createReview: (payload: CreateReviewPayload) =>
|
||||
request<ReviewRecord>('/api/reviews', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateReview: (id: number, payload: UpdateReviewPayload) =>
|
||||
request<ReviewRecord>(`/api/reviews/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteReview: (id: number) =>
|
||||
request<void>(`/api/reviews/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
|
||||
28
admin/src/lib/frontend-url.ts
Normal file
28
admin/src/lib/frontend-url.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
|
||||
|
||||
const envFrontendBaseUrl = normalizeAdminBaseUrl(import.meta.env.VITE_FRONTEND_BASE_URL)
|
||||
const DEV_FRONTEND_BASE_URL = 'http://localhost:4321'
|
||||
const PROD_DEFAULT_FRONTEND_PORT = '4321'
|
||||
|
||||
export function getFrontendBaseUrl() {
|
||||
const runtimeFrontendBaseUrl = getRuntimeAdminBaseUrl('frontendBaseUrl')
|
||||
if (runtimeFrontendBaseUrl) {
|
||||
return runtimeFrontendBaseUrl
|
||||
}
|
||||
|
||||
if (envFrontendBaseUrl) {
|
||||
return envFrontendBaseUrl
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return DEV_FRONTEND_BASE_URL
|
||||
}
|
||||
|
||||
const { protocol, hostname } = window.location
|
||||
return `${protocol}//${hostname}:${PROD_DEFAULT_FRONTEND_PORT}`
|
||||
}
|
||||
|
||||
export function buildFrontendUrl(path = '/') {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${getFrontendBaseUrl()}${normalizedPath}`
|
||||
}
|
||||
279
admin/src/lib/image-compress.ts
Normal file
279
admin/src/lib/image-compress.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
export interface CompressionPreview {
|
||||
originalSize: number
|
||||
compressedSize: number
|
||||
savedBytes: number
|
||||
savedRatio: number
|
||||
}
|
||||
|
||||
export interface CompressionResult {
|
||||
file: File
|
||||
usedCompressed: boolean
|
||||
preview: CompressionPreview | null
|
||||
}
|
||||
|
||||
interface ProcessImageOptions {
|
||||
quality: number
|
||||
maxWidth: number
|
||||
maxHeight: number
|
||||
preferredFormats: string[]
|
||||
coverWidth?: number
|
||||
coverHeight?: number
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unit = 0
|
||||
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024
|
||||
unit += 1
|
||||
}
|
||||
|
||||
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function canTransformWithCanvas(file: File) {
|
||||
return file.type.startsWith('image/') && file.type !== 'image/svg+xml' && file.type !== 'image/gif'
|
||||
}
|
||||
|
||||
async function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
preferredFormats: string[],
|
||||
quality: number,
|
||||
) {
|
||||
for (const format of preferredFormats) {
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, format, quality)
|
||||
})
|
||||
|
||||
if (blob && blob.type === format) {
|
||||
return blob
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
function extensionForMimeType(mimeType: string) {
|
||||
switch (mimeType) {
|
||||
case 'image/avif':
|
||||
return '.avif'
|
||||
case 'image/webp':
|
||||
return '.webp'
|
||||
case 'image/png':
|
||||
return '.png'
|
||||
default:
|
||||
return '.jpg'
|
||||
}
|
||||
}
|
||||
|
||||
function deriveFileName(file: File, mimeType: string) {
|
||||
const extension = extensionForMimeType(mimeType)
|
||||
if (/\.[A-Za-z0-9]+$/.test(file.name)) {
|
||||
return file.name.replace(/\.[A-Za-z0-9]+$/, extension)
|
||||
}
|
||||
|
||||
return `processed${extension}`
|
||||
}
|
||||
|
||||
async function processImage(file: File, options: ProcessImageOptions): Promise<File> {
|
||||
if (!canTransformWithCanvas(file)) {
|
||||
return file
|
||||
}
|
||||
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
if (options.coverWidth && options.coverHeight) {
|
||||
canvas.width = options.coverWidth
|
||||
canvas.height = options.coverHeight
|
||||
} else {
|
||||
const scale = Math.min(
|
||||
options.maxWidth / bitmap.width,
|
||||
options.maxHeight / bitmap.height,
|
||||
1,
|
||||
)
|
||||
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
return file
|
||||
}
|
||||
|
||||
if (options.coverWidth && options.coverHeight) {
|
||||
const scale = Math.max(
|
||||
options.coverWidth / bitmap.width,
|
||||
options.coverHeight / bitmap.height,
|
||||
)
|
||||
const drawWidth = bitmap.width * scale
|
||||
const drawHeight = bitmap.height * scale
|
||||
const offsetX = (options.coverWidth - drawWidth) / 2
|
||||
const offsetY = (options.coverHeight - drawHeight) / 2
|
||||
|
||||
ctx.drawImage(bitmap, offsetX, offsetY, drawWidth, drawHeight)
|
||||
} else {
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
const blob = await canvasToBlob(canvas, options.preferredFormats, options.quality)
|
||||
if (!blob) {
|
||||
return file
|
||||
}
|
||||
|
||||
return new File([blob], deriveFileName(file, blob.type), {
|
||||
type: blob.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
async function maybeProcessImageWithPrompt(
|
||||
file: File,
|
||||
options?: {
|
||||
quality?: number
|
||||
ask?: boolean
|
||||
minSavingsRatio?: number
|
||||
contextLabel?: string
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
preferredFormats?: string[]
|
||||
coverWidth?: number
|
||||
coverHeight?: number
|
||||
forceProcessed?: boolean
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
if (!canTransformWithCanvas(file)) {
|
||||
return { file, usedCompressed: false, preview: null }
|
||||
}
|
||||
|
||||
const quality = Math.min(Math.max(options?.quality ?? 0.82, 0.45), 0.95)
|
||||
const minSavingsRatio = Math.min(Math.max(options?.minSavingsRatio ?? 0.03, 0), 0.9)
|
||||
const ask = options?.ask ?? true
|
||||
const contextLabel = options?.contextLabel ?? '图片上传'
|
||||
const forceProcessed = options?.forceProcessed ?? false
|
||||
|
||||
let processed: File
|
||||
try {
|
||||
processed = await processImage(file, {
|
||||
quality,
|
||||
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
|
||||
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
|
||||
preferredFormats:
|
||||
options?.preferredFormats && options.preferredFormats.length
|
||||
? options.preferredFormats
|
||||
: file.type === 'image/png'
|
||||
? ['image/png', 'image/webp', 'image/jpeg']
|
||||
: ['image/webp', 'image/avif', 'image/jpeg'],
|
||||
coverWidth: options?.coverWidth,
|
||||
coverHeight: options?.coverHeight,
|
||||
})
|
||||
} catch {
|
||||
return { file, usedCompressed: false, preview: null }
|
||||
}
|
||||
|
||||
const savedBytes = file.size - processed.size
|
||||
const savedRatio = file.size > 0 ? savedBytes / file.size : 0
|
||||
const preview: CompressionPreview = {
|
||||
originalSize: file.size,
|
||||
compressedSize: processed.size,
|
||||
savedBytes,
|
||||
savedRatio,
|
||||
}
|
||||
|
||||
if (!forceProcessed && processed.size >= file.size) {
|
||||
return { file, usedCompressed: false, preview }
|
||||
}
|
||||
|
||||
if (!forceProcessed && savedRatio < minSavingsRatio) {
|
||||
return { file, usedCompressed: false, preview }
|
||||
}
|
||||
|
||||
if (!ask) {
|
||||
return { file: processed, usedCompressed: true, preview }
|
||||
}
|
||||
|
||||
const deltaText =
|
||||
savedBytes >= 0
|
||||
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)`
|
||||
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)`
|
||||
|
||||
const intro = forceProcessed
|
||||
? `${contextLabel}: 已生成规范化版本。`
|
||||
: `${contextLabel}: 检测到可压缩空间。`
|
||||
|
||||
const useProcessed = window.confirm(
|
||||
[
|
||||
intro,
|
||||
`原始: ${formatBytes(file.size)}`,
|
||||
`处理后: ${formatBytes(processed.size)}`,
|
||||
deltaText,
|
||||
'',
|
||||
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
return {
|
||||
file: useProcessed ? processed : file,
|
||||
usedCompressed: useProcessed,
|
||||
preview,
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeCompressImageWithPrompt(
|
||||
file: File,
|
||||
options?: {
|
||||
quality?: number
|
||||
ask?: boolean
|
||||
minSavingsRatio?: number
|
||||
contextLabel?: string
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
return maybeProcessImageWithPrompt(file, options)
|
||||
}
|
||||
|
||||
export async function normalizeCoverImageWithPrompt(
|
||||
file: File,
|
||||
options?: {
|
||||
quality?: number
|
||||
ask?: boolean
|
||||
contextLabel?: string
|
||||
width?: number
|
||||
height?: number
|
||||
},
|
||||
): Promise<CompressionResult> {
|
||||
return maybeProcessImageWithPrompt(file, {
|
||||
quality: options?.quality ?? 0.82,
|
||||
ask: options?.ask ?? true,
|
||||
contextLabel: options?.contextLabel ?? '封面图规范化',
|
||||
preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'],
|
||||
coverWidth: Math.max(options?.width ?? 1600, 640),
|
||||
coverHeight: Math.max(options?.height ?? 900, 360),
|
||||
forceProcessed: true,
|
||||
minSavingsRatio: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatCompressionPreview(preview: CompressionPreview | null) {
|
||||
if (!preview) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (preview.savedBytes >= 0) {
|
||||
return `原始 ${formatBytes(preview.originalSize)} → 处理后 ${formatBytes(
|
||||
preview.compressedSize,
|
||||
)},节省 ${(preview.savedRatio * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
return `原始 ${formatBytes(preview.originalSize)} → 处理后 ${formatBytes(
|
||||
preview.compressedSize,
|
||||
)},体积增加 ${Math.abs(preview.savedRatio * 100).toFixed(1)}%`
|
||||
}
|
||||
32
admin/src/lib/markdown-diff.ts
Normal file
32
admin/src/lib/markdown-diff.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function normalizeMarkdown(value: string) {
|
||||
return value.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
export function countLineDiff(left: string, right: string) {
|
||||
const leftLines = normalizeMarkdown(left).split('\n')
|
||||
const rightLines = normalizeMarkdown(right).split('\n')
|
||||
const previous = new Array(rightLines.length + 1).fill(0)
|
||||
|
||||
for (let leftIndex = 1; leftIndex <= leftLines.length; leftIndex += 1) {
|
||||
const current = new Array(rightLines.length + 1).fill(0)
|
||||
|
||||
for (let rightIndex = 1; rightIndex <= rightLines.length; rightIndex += 1) {
|
||||
if (leftLines[leftIndex - 1] === rightLines[rightIndex - 1]) {
|
||||
current[rightIndex] = previous[rightIndex - 1] + 1
|
||||
} else {
|
||||
current[rightIndex] = Math.max(previous[rightIndex], current[rightIndex - 1])
|
||||
}
|
||||
}
|
||||
|
||||
for (let rightIndex = 0; rightIndex <= rightLines.length; rightIndex += 1) {
|
||||
previous[rightIndex] = current[rightIndex]
|
||||
}
|
||||
}
|
||||
|
||||
const common = previous[rightLines.length]
|
||||
|
||||
return {
|
||||
additions: Math.max(rightLines.length - common, 0),
|
||||
deletions: Math.max(leftLines.length - common, 0),
|
||||
}
|
||||
}
|
||||
328
admin/src/lib/markdown-document.ts
Normal file
328
admin/src/lib/markdown-document.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
|
||||
export type ParsedMarkdownMeta = {
|
||||
title: string
|
||||
slug: string
|
||||
description: string
|
||||
category: string
|
||||
postType: string
|
||||
image: string
|
||||
images: string[]
|
||||
pinned: boolean
|
||||
status: string
|
||||
visibility: string
|
||||
publishAt: string
|
||||
unpublishAt: string
|
||||
canonicalUrl: string
|
||||
noindex: boolean
|
||||
ogImage: string
|
||||
redirectFrom: string[]
|
||||
redirectTo: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export type ParsedMarkdownDocument = {
|
||||
meta: ParsedMarkdownMeta
|
||||
body: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
const defaultMeta: ParsedMarkdownMeta = {
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
category: '',
|
||||
postType: 'article',
|
||||
image: '',
|
||||
images: [],
|
||||
pinned: false,
|
||||
status: 'published',
|
||||
visibility: 'public',
|
||||
publishAt: '',
|
||||
unpublishAt: '',
|
||||
canonicalUrl: '',
|
||||
noindex: false,
|
||||
ogImage: '',
|
||||
redirectFrom: [],
|
||||
redirectTo: '',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
function parseScalar(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.startsWith('"') ||
|
||||
trimmed.startsWith("'") ||
|
||||
trimmed.startsWith('[') ||
|
||||
trimmed.startsWith('{')
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return trimmed.replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (trimmed === 'false') {
|
||||
return false
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function toStringList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(/[,,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument {
|
||||
const normalized = normalizeMarkdown(markdown)
|
||||
const meta: ParsedMarkdownMeta = { ...defaultMeta }
|
||||
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return {
|
||||
meta,
|
||||
body: normalized.trimStart(),
|
||||
markdown: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
const endIndex = normalized.indexOf('\n---\n', 4)
|
||||
if (endIndex === -1) {
|
||||
return {
|
||||
meta,
|
||||
body: normalized.trimStart(),
|
||||
markdown: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
const frontmatter = normalized.slice(4, endIndex)
|
||||
const body = normalized.slice(endIndex + 5).trimStart()
|
||||
let currentListKey: 'tags' | 'images' | 'categories' | 'redirect_from' | null = null
|
||||
const categories: string[] = []
|
||||
|
||||
frontmatter.split('\n').forEach((line) => {
|
||||
const listItemMatch = line.match(/^\s*-\s*(.+)\s*$/)
|
||||
if (listItemMatch && currentListKey) {
|
||||
const parsed = parseScalar(listItemMatch[1])
|
||||
const nextValue = typeof parsed === 'string' ? parsed.trim() : String(parsed).trim()
|
||||
if (!nextValue) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentListKey === 'tags') {
|
||||
meta.tags.push(nextValue)
|
||||
} else if (currentListKey === 'images') {
|
||||
meta.images.push(nextValue)
|
||||
} else if (currentListKey === 'redirect_from') {
|
||||
meta.redirectFrom.push(nextValue)
|
||||
} else {
|
||||
categories.push(nextValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
currentListKey = null
|
||||
|
||||
const keyMatch = line.match(/^([A-Za-z_]+):\s*(.*)$/)
|
||||
if (!keyMatch) {
|
||||
return
|
||||
}
|
||||
|
||||
const [, rawKey, rawValue] = keyMatch
|
||||
const key = rawKey.trim()
|
||||
const value = parseScalar(rawValue)
|
||||
|
||||
if (key === 'tags') {
|
||||
const tags = toStringList(value)
|
||||
if (tags.length) {
|
||||
meta.tags = tags
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'tags'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'images') {
|
||||
const images = toStringList(value)
|
||||
if (images.length) {
|
||||
meta.images = images
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'images'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'redirect_from') {
|
||||
const redirectFrom = toStringList(value)
|
||||
if (redirectFrom.length) {
|
||||
meta.redirectFrom = redirectFrom
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'redirect_from'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'categories' || key === 'category') {
|
||||
const parsedCategories = toStringList(value)
|
||||
if (parsedCategories.length) {
|
||||
categories.push(...parsedCategories)
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'categories'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'title':
|
||||
meta.title = String(value).trim()
|
||||
break
|
||||
case 'slug':
|
||||
meta.slug = String(value).trim()
|
||||
break
|
||||
case 'description':
|
||||
meta.description = String(value).trim()
|
||||
break
|
||||
case 'post_type':
|
||||
meta.postType = String(value).trim() || 'article'
|
||||
break
|
||||
case 'image':
|
||||
meta.image = String(value).trim()
|
||||
break
|
||||
case 'pinned':
|
||||
meta.pinned = Boolean(value)
|
||||
break
|
||||
case 'status':
|
||||
meta.status = String(value).trim() || 'published'
|
||||
break
|
||||
case 'visibility':
|
||||
meta.visibility = String(value).trim() || 'public'
|
||||
break
|
||||
case 'publish_at':
|
||||
meta.publishAt = String(value).trim()
|
||||
break
|
||||
case 'unpublish_at':
|
||||
meta.unpublishAt = String(value).trim()
|
||||
break
|
||||
case 'canonical_url':
|
||||
meta.canonicalUrl = String(value).trim()
|
||||
break
|
||||
case 'noindex':
|
||||
meta.noindex = Boolean(value)
|
||||
break
|
||||
case 'og_image':
|
||||
meta.ogImage = String(value).trim()
|
||||
break
|
||||
case 'redirect_to':
|
||||
meta.redirectTo = String(value).trim()
|
||||
break
|
||||
case 'published':
|
||||
meta.status = value === false ? 'draft' : 'published'
|
||||
break
|
||||
case 'draft':
|
||||
if (value === true) {
|
||||
meta.status = 'draft'
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
meta.category = categories[0] ?? meta.category
|
||||
|
||||
return {
|
||||
meta,
|
||||
body,
|
||||
markdown: normalized,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
|
||||
const lines = [
|
||||
'---',
|
||||
`title: ${JSON.stringify(meta.title.trim() || meta.slug || 'untitled-post')}`,
|
||||
`slug: ${meta.slug.trim() || 'untitled-post'}`,
|
||||
]
|
||||
|
||||
if (meta.description.trim()) {
|
||||
lines.push(`description: ${JSON.stringify(meta.description.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.category.trim()) {
|
||||
lines.push(`category: ${JSON.stringify(meta.category.trim())}`)
|
||||
}
|
||||
|
||||
lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`)
|
||||
lines.push(`pinned: ${meta.pinned ? 'true' : 'false'}`)
|
||||
lines.push(`status: ${JSON.stringify(meta.status.trim() || 'published')}`)
|
||||
lines.push(`visibility: ${JSON.stringify(meta.visibility.trim() || 'public')}`)
|
||||
lines.push(`noindex: ${meta.noindex ? 'true' : 'false'}`)
|
||||
|
||||
if (meta.publishAt.trim()) {
|
||||
lines.push(`publish_at: ${JSON.stringify(meta.publishAt.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.unpublishAt.trim()) {
|
||||
lines.push(`unpublish_at: ${JSON.stringify(meta.unpublishAt.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.image.trim()) {
|
||||
lines.push(`image: ${JSON.stringify(meta.image.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.images.length) {
|
||||
lines.push('images:')
|
||||
meta.images.forEach((image) => {
|
||||
lines.push(` - ${JSON.stringify(image)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.tags.length) {
|
||||
lines.push('tags:')
|
||||
meta.tags.forEach((tag) => {
|
||||
lines.push(` - ${JSON.stringify(tag)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.canonicalUrl.trim()) {
|
||||
lines.push(`canonical_url: ${JSON.stringify(meta.canonicalUrl.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.ogImage.trim()) {
|
||||
lines.push(`og_image: ${JSON.stringify(meta.ogImage.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.redirectFrom.length) {
|
||||
lines.push('redirect_from:')
|
||||
meta.redirectFrom.forEach((item) => {
|
||||
lines.push(` - ${JSON.stringify(item)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.redirectTo.trim()) {
|
||||
lines.push(`redirect_to: ${JSON.stringify(meta.redirectTo.trim())}`)
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n---\n\n${body.trim()}\n`
|
||||
}
|
||||
149
admin/src/lib/markdown-merge.ts
Normal file
149
admin/src/lib/markdown-merge.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
|
||||
type DiffOperation =
|
||||
| { type: 'equal'; line: string }
|
||||
| { type: 'delete'; line: string }
|
||||
| { type: 'insert'; line: string }
|
||||
|
||||
export type DiffHunk = {
|
||||
id: string
|
||||
originalStart: number
|
||||
originalEnd: number
|
||||
modifiedStart: number
|
||||
modifiedEnd: number
|
||||
removedLines: string[]
|
||||
addedLines: string[]
|
||||
preview: string
|
||||
}
|
||||
|
||||
function diffOperations(originalLines: string[], modifiedLines: string[]) {
|
||||
const rows = originalLines.length
|
||||
const cols = modifiedLines.length
|
||||
const dp = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0))
|
||||
|
||||
for (let row = 1; row <= rows; row += 1) {
|
||||
for (let col = 1; col <= cols; col += 1) {
|
||||
if (originalLines[row - 1] === modifiedLines[col - 1]) {
|
||||
dp[row][col] = dp[row - 1][col - 1] + 1
|
||||
} else {
|
||||
dp[row][col] = Math.max(dp[row - 1][col], dp[row][col - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const operations: DiffOperation[] = []
|
||||
let row = rows
|
||||
let col = cols
|
||||
|
||||
while (row > 0 || col > 0) {
|
||||
if (row > 0 && col > 0 && originalLines[row - 1] === modifiedLines[col - 1]) {
|
||||
operations.push({ type: 'equal', line: originalLines[row - 1] })
|
||||
row -= 1
|
||||
col -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (col > 0 && (row === 0 || dp[row][col - 1] >= dp[row - 1][col])) {
|
||||
operations.push({ type: 'insert', line: modifiedLines[col - 1] })
|
||||
col -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
operations.push({ type: 'delete', line: originalLines[row - 1] })
|
||||
row -= 1
|
||||
}
|
||||
|
||||
return operations.reverse()
|
||||
}
|
||||
|
||||
export function computeDiffHunks(original: string, modified: string): DiffHunk[] {
|
||||
const originalLines = normalizeMarkdown(original).split('\n')
|
||||
const modifiedLines = normalizeMarkdown(modified).split('\n')
|
||||
const operations = diffOperations(originalLines, modifiedLines)
|
||||
const hunks: DiffHunk[] = []
|
||||
let originalLine = 1
|
||||
let modifiedLine = 1
|
||||
let current:
|
||||
| (Omit<DiffHunk, 'id' | 'originalEnd' | 'modifiedEnd' | 'preview'> & {
|
||||
idSeed: number
|
||||
})
|
||||
| null = null
|
||||
|
||||
const flush = () => {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
const previewSource = current.addedLines.join(' ').trim() || current.removedLines.join(' ').trim()
|
||||
hunks.push({
|
||||
id: `hunk-${current.idSeed}`,
|
||||
originalStart: current.originalStart,
|
||||
originalEnd: originalLine - 1,
|
||||
modifiedStart: current.modifiedStart,
|
||||
modifiedEnd: modifiedLine - 1,
|
||||
removedLines: current.removedLines,
|
||||
addedLines: current.addedLines,
|
||||
preview: previewSource.slice(0, 120) || '空白改动',
|
||||
})
|
||||
current = null
|
||||
}
|
||||
|
||||
operations.forEach((operation) => {
|
||||
if (operation.type === 'equal') {
|
||||
flush()
|
||||
originalLine += 1
|
||||
modifiedLine += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = {
|
||||
idSeed: hunks.length + 1,
|
||||
originalStart: originalLine,
|
||||
modifiedStart: modifiedLine,
|
||||
removedLines: [],
|
||||
addedLines: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (operation.type === 'delete') {
|
||||
current.removedLines.push(operation.line)
|
||||
originalLine += 1
|
||||
return
|
||||
}
|
||||
|
||||
current.addedLines.push(operation.line)
|
||||
modifiedLine += 1
|
||||
})
|
||||
|
||||
flush()
|
||||
|
||||
return hunks
|
||||
}
|
||||
|
||||
export function applySelectedDiffHunks(
|
||||
original: string,
|
||||
hunks: DiffHunk[],
|
||||
selectedIds: Set<string>,
|
||||
) {
|
||||
const originalLines = normalizeMarkdown(original).split('\n')
|
||||
const resultLines: string[] = []
|
||||
let cursor = 1
|
||||
|
||||
hunks.forEach((hunk) => {
|
||||
const unchangedEnd = Math.max(hunk.originalStart - 1, cursor - 1)
|
||||
resultLines.push(...originalLines.slice(cursor - 1, unchangedEnd))
|
||||
|
||||
if (selectedIds.has(hunk.id)) {
|
||||
resultLines.push(...hunk.addedLines)
|
||||
} else if (hunk.originalEnd >= hunk.originalStart) {
|
||||
resultLines.push(...originalLines.slice(hunk.originalStart - 1, hunk.originalEnd))
|
||||
}
|
||||
|
||||
cursor = Math.max(hunk.originalEnd + 1, hunk.originalStart)
|
||||
})
|
||||
|
||||
resultLines.push(...originalLines.slice(cursor - 1))
|
||||
|
||||
return resultLines.join('\n')
|
||||
}
|
||||
82
admin/src/lib/post-draft-window.ts
Normal file
82
admin/src/lib/post-draft-window.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type DraftWindowSnapshot = {
|
||||
title: string
|
||||
slug: string
|
||||
path: string
|
||||
markdown: string
|
||||
savedMarkdown: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'termi-admin-post-draft:'
|
||||
const POLISH_RESULT_PREFIX = 'termi-admin-post-polish-result:'
|
||||
|
||||
export type PolishWindowResult = {
|
||||
draftKey: string
|
||||
markdown: string
|
||||
target: 'editor' | 'create'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function saveDraftWindowSnapshot(snapshot: Omit<DraftWindowSnapshot, 'createdAt'>) {
|
||||
const key = `${STORAGE_PREFIX}${snapshot.slug}:${Date.now()}`
|
||||
const payload: DraftWindowSnapshot = {
|
||||
...snapshot,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(payload))
|
||||
return key
|
||||
}
|
||||
|
||||
export function loadDraftWindowSnapshot(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as DraftWindowSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function savePolishWindowResult(
|
||||
draftKey: string,
|
||||
markdown: string,
|
||||
target: 'editor' | 'create',
|
||||
) {
|
||||
const payload: PolishWindowResult = {
|
||||
draftKey,
|
||||
markdown,
|
||||
target,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
window.localStorage.setItem(`${POLISH_RESULT_PREFIX}${draftKey}`, JSON.stringify(payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
export function consumePolishWindowResult(key: string | null) {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storageKey = `${POLISH_RESULT_PREFIX}${key}`
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(storageKey)
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as PolishWindowResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
22
admin/src/lib/runtime-config.ts
Normal file
22
admin/src/lib/runtime-config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TermiAdminRuntimeConfig = {
|
||||
apiBaseUrl?: string
|
||||
frontendBaseUrl?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TERMI_ADMIN_RUNTIME_CONFIG__?: TermiAdminRuntimeConfig
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAdminBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? ''
|
||||
}
|
||||
|
||||
export function getRuntimeAdminBaseUrl(key: keyof TermiAdminRuntimeConfig) {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalizeAdminBaseUrl(window.__TERMI_ADMIN_RUNTIME_CONFIG__?.[key])
|
||||
}
|
||||
@@ -1,12 +1,140 @@
|
||||
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
|
||||
@@ -23,6 +151,8 @@ export interface DashboardPostItem {
|
||||
category: string
|
||||
post_type: string
|
||||
pinned: boolean
|
||||
status: string
|
||||
visibility: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -71,6 +201,85 @@ export interface AdminDashboardResponse {
|
||||
recent_reviews: DashboardReviewItem[]
|
||||
}
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
total_searches: number
|
||||
total_ai_questions: number
|
||||
searches_last_24h: number
|
||||
ai_questions_last_24h: number
|
||||
searches_last_7d: number
|
||||
ai_questions_last_7d: number
|
||||
unique_search_terms_last_7d: number
|
||||
unique_ai_questions_last_7d: number
|
||||
avg_search_results_last_7d: number
|
||||
avg_ai_latency_ms_last_7d: number | null
|
||||
}
|
||||
|
||||
export interface ContentAnalyticsOverview {
|
||||
total_page_views: number
|
||||
page_views_last_24h: number
|
||||
page_views_last_7d: number
|
||||
total_read_completes: number
|
||||
read_completes_last_7d: number
|
||||
avg_read_progress_last_7d: number
|
||||
avg_read_duration_ms_last_7d: number | null
|
||||
}
|
||||
|
||||
export interface AnalyticsTopQuery {
|
||||
query: string
|
||||
count: number
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
export interface AnalyticsRecentEvent {
|
||||
id: number
|
||||
event_type: string
|
||||
query: string
|
||||
result_count: number | null
|
||||
success: boolean | null
|
||||
response_mode: string | null
|
||||
provider: string | null
|
||||
chat_model: string | null
|
||||
latency_ms: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AnalyticsProviderBucket {
|
||||
provider: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AnalyticsReferrerBucket {
|
||||
referrer: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AnalyticsPopularPost {
|
||||
slug: string
|
||||
title: string
|
||||
page_views: number
|
||||
read_completes: number
|
||||
avg_progress_percent: number
|
||||
avg_duration_ms: number | null
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyBucket {
|
||||
date: string
|
||||
searches: number
|
||||
ai_questions: number
|
||||
}
|
||||
|
||||
export interface AdminAnalyticsResponse {
|
||||
overview: AnalyticsOverview
|
||||
content_overview: ContentAnalyticsOverview
|
||||
top_search_terms: AnalyticsTopQuery[]
|
||||
top_ai_questions: AnalyticsTopQuery[]
|
||||
recent_events: AnalyticsRecentEvent[]
|
||||
providers_last_7d: AnalyticsProviderBucket[]
|
||||
top_referrers: AnalyticsReferrerBucket[]
|
||||
popular_posts: AnalyticsPopularPost[]
|
||||
daily_activity: AnalyticsDailyBucket[]
|
||||
}
|
||||
|
||||
export interface AdminSiteSettingsResponse {
|
||||
id: number
|
||||
site_name: string | null
|
||||
@@ -89,11 +298,19 @@ export interface AdminSiteSettingsResponse {
|
||||
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
|
||||
@@ -101,6 +318,28 @@ export interface AdminSiteSettingsResponse {
|
||||
ai_last_indexed_at: string | null
|
||||
ai_chunks_count: number
|
||||
ai_local_embedding: string
|
||||
media_storage_provider: string | null
|
||||
media_r2_account_id: string | null
|
||||
media_r2_bucket: string | null
|
||||
media_r2_public_base_url: string | null
|
||||
media_r2_access_key_id: string | null
|
||||
media_r2_secret_access_key: string | null
|
||||
seo_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 {
|
||||
@@ -120,18 +359,410 @@ export interface SiteSettingsPayload {
|
||||
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
|
||||
}
|
||||
|
||||
563
admin/src/pages/analytics-page.tsx
Normal file
563
admin/src/pages/analytics-page.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
import type { AdminAnalyticsResponse } from '@/lib/types'
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
icon: typeof Search
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||
<CardContent className="flex items-start justify-between pt-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEventType(value: string) {
|
||||
return value === 'ai_question' ? 'AI 问答' : '站内搜索'
|
||||
}
|
||||
|
||||
function formatSuccess(value: boolean | null) {
|
||||
if (value === null) {
|
||||
return '未记录'
|
||||
}
|
||||
|
||||
return value ? '成功' : '失败'
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${Math.round(value)}%`
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value) || value <= 0) {
|
||||
return '暂无'
|
||||
}
|
||||
|
||||
if (value < 1000) {
|
||||
return `${Math.round(value)} ms`
|
||||
}
|
||||
|
||||
const seconds = value / 1000
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} 秒`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const restSeconds = Math.round(seconds % 60)
|
||||
return `${minutes} 分 ${restSeconds} 秒`
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const loadAnalytics = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.analytics()
|
||||
startTransition(() => {
|
||||
setData(next)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('数据分析已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载数据分析。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalytics(false)
|
||||
}, [loadAnalytics])
|
||||
|
||||
const maxDailyTotal = useMemo(() => {
|
||||
if (!data?.daily_activity.length) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
...data.daily_activity.map((item) => item.searches + item.ai_questions),
|
||||
1,
|
||||
)
|
||||
}, [data])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-44 rounded-3xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: '累计搜索',
|
||||
value: String(data.overview.total_searches),
|
||||
note: `近 7 天 ${data.overview.searches_last_7d} 次,平均命中 ${data.overview.avg_search_results_last_7d.toFixed(1)} 条`,
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
label: '累计 AI 提问',
|
||||
value: String(data.overview.total_ai_questions),
|
||||
note: `近 7 天 ${data.overview.ai_questions_last_7d} 次`,
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
{
|
||||
label: '24 小时活跃',
|
||||
value: String(data.overview.searches_last_24h + data.overview.ai_questions_last_24h),
|
||||
note: `搜索 ${data.overview.searches_last_24h} / AI ${data.overview.ai_questions_last_24h}`,
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: '近 7 天去重词',
|
||||
value: String(
|
||||
data.overview.unique_search_terms_last_7d +
|
||||
data.overview.unique_ai_questions_last_7d,
|
||||
),
|
||||
note: `搜索 ${data.overview.unique_search_terms_last_7d} / AI ${data.overview.unique_ai_questions_last_7d}`,
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
|
||||
const contentStatCards = [
|
||||
{
|
||||
label: '累计页面访问',
|
||||
value: String(data.content_overview.total_page_views),
|
||||
note: `近 24 小时 ${data.content_overview.page_views_last_24h} 次,近 7 天 ${data.content_overview.page_views_last_7d} 次`,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: '累计完读次数',
|
||||
value: String(data.content_overview.total_read_completes),
|
||||
note: `近 7 天新增 ${data.content_overview.read_completes_last_7d} 次 read_complete`,
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
label: '近 7 天平均进度',
|
||||
value: formatPercent(data.content_overview.avg_read_progress_last_7d),
|
||||
note: '基于 read_progress / read_complete 事件估算内容消费深度',
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
label: '近 7 天平均阅读时长',
|
||||
value: formatDuration(data.content_overview.avg_read_duration_ms_last_7d),
|
||||
note: '同一会话在文章页停留并产生阅读进度的平均时长',
|
||||
icon: Clock3,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">数据分析</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">前台搜索、阅读行为与 AI 问答洞察</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
这里会同时记录站内搜索、AI 提问、页面访问、阅读进度和来源分析,方便你判断内容需求、热门文章和站点增长质量。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
|
||||
<BrainCircuit className="h-4 w-4" />
|
||||
打开问答页
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadAnalytics(true)}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{statCards.map((item) => (
|
||||
<StatCard key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{contentStatCards.map((item) => (
|
||||
<StatCard key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>最近记录</CardTitle>
|
||||
<CardDescription>
|
||||
最近一批真实发生的搜索和 AI 问答请求。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_events.length} 条</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>内容</TableHead>
|
||||
<TableHead>结果</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.recent_events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={event.event_type === 'ai_question' ? 'secondary' : 'outline'}>
|
||||
{formatEventType(event.event_type)}
|
||||
</Badge>
|
||||
{event.response_mode ? (
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{event.response_mode}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="line-clamp-2 font-medium">{event.query}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{event.provider ? `${event.provider}` : '未记录渠道'}
|
||||
{event.chat_model ? ` / ${event.chat_model}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div>{formatSuccess(event.success)}</div>
|
||||
<div className="mt-1">
|
||||
{event.result_count !== null ? `${event.result_count} 条/源` : '无'}
|
||||
</div>
|
||||
{event.latency_ms !== null ? (
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em]">
|
||||
{event.latency_ms} ms
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{event.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>热门文章榜</CardTitle>
|
||||
<CardDescription>
|
||||
基于 page_view / read_complete 事件聚合的内容表现排行。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.popular_posts.length} 篇</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文章</TableHead>
|
||||
<TableHead>浏览</TableHead>
|
||||
<TableHead>完读</TableHead>
|
||||
<TableHead>平均进度</TableHead>
|
||||
<TableHead>平均时长</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.popular_posts.length ? (
|
||||
data.popular_posts.map((post) => (
|
||||
<TableRow key={post.slug}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<a
|
||||
href={buildFrontendUrl(`/articles/${post.slug}`)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{post.title}
|
||||
</a>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{post.slug}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{post.page_views}</TableCell>
|
||||
<TableCell>{post.read_completes}</TableCell>
|
||||
<TableCell>{formatPercent(post.avg_progress_percent)}</TableCell>
|
||||
<TableCell>{formatDuration(post.avg_duration_ms)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-sm text-muted-foreground">
|
||||
还没有足够的内容访问数据。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>热门搜索词</CardTitle>
|
||||
<CardDescription>
|
||||
近 7 天最常被搜索的关键词。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.top_search_terms.length} 个</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.top_search_terms.length ? (
|
||||
data.top_search_terms.map((item) => (
|
||||
<div
|
||||
key={`${item.query}-${item.last_seen_at}`}
|
||||
className="rounded-2xl border border-border/70 bg-background/70 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-medium">{item.query}</p>
|
||||
<Badge variant="secondary">{item.count}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
最近一次:{item.last_seen_at}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有站内搜索记录。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>热门 AI 问题</CardTitle>
|
||||
<CardDescription>
|
||||
近 7 天重复出现最多的提问。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.top_ai_questions.length} 个</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.top_ai_questions.length ? (
|
||||
data.top_ai_questions.map((item) => (
|
||||
<div
|
||||
key={`${item.query}-${item.last_seen_at}`}
|
||||
className="rounded-2xl border border-border/70 bg-background/70 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-medium">{item.query}</p>
|
||||
<Badge variant="secondary">{item.count}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
最近一次:{item.last_seen_at}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有 AI 提问记录。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>内容消费概览</CardTitle>
|
||||
<CardDescription>
|
||||
浏览量、完读率和阅读时长的快速摘要。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
24 小时页面访问
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{data.content_overview.page_views_last_24h}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
近 7 天完读
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{data.content_overview.read_completes_last_7d}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
近 7 天平均阅读进度
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{formatPercent(data.content_overview.avg_read_progress_last_7d)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">统计范围:最近 7 天</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
近 7 天平均阅读时长
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{formatDuration(data.content_overview.avg_read_duration_ms_last_7d)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">同一会话聚合后的阅读耗时</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>来源分析</CardTitle>
|
||||
<CardDescription>
|
||||
近 7 天触发 page_view 的主要 referrer host。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.top_referrers.length ? (
|
||||
data.top_referrers.map((item) => (
|
||||
<div
|
||||
key={item.referrer}
|
||||
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
||||
>
|
||||
<span className="line-clamp-1 font-medium">{item.referrer}</span>
|
||||
<Badge variant="outline">{item.count}</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有来源分析数据。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型渠道分布</CardTitle>
|
||||
<CardDescription>
|
||||
最近 7 天 AI 请求实际使用的 provider 厂商。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.providers_last_7d.length ? (
|
||||
data.providers_last_7d.map((item) => (
|
||||
<div
|
||||
key={item.provider}
|
||||
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
|
||||
>
|
||||
<span className="font-medium">{item.provider}</span>
|
||||
<Badge variant="outline">{item.count}</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">最近 7 天还没有 AI 渠道数据。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>7 天走势</CardTitle>
|
||||
<CardDescription>
|
||||
搜索与 AI 问答的日维度活动量。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.daily_activity.map((item) => {
|
||||
const total = item.searches + item.ai_questions
|
||||
const width = `${Math.max((total / maxDailyTotal) * 100, total > 0 ? 12 : 0)}%`
|
||||
|
||||
return (
|
||||
<div key={item.date} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium">{item.date}</span>
|
||||
<span className="text-muted-foreground">
|
||||
搜索 {item.searches} / AI {item.ai_questions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
admin/src/pages/audit-page.tsx
Normal file
166
admin/src/pages/audit-page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { AuditLogRecord } from '@/lib/types'
|
||||
|
||||
export function AuditPage() {
|
||||
const [logs, setLogs] = useState<AuditLogRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
const loadLogs = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const next = await adminApi.listAuditLogs({ limit: 120 })
|
||||
startTransition(() => {
|
||||
setLogs(next)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('审计日志已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载审计日志。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs(false)
|
||||
}, [loadLogs])
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return logs
|
||||
}
|
||||
|
||||
return logs.filter((log) =>
|
||||
[
|
||||
log.action,
|
||||
log.target_type,
|
||||
log.target_id ?? '',
|
||||
log.target_label ?? '',
|
||||
log.actor_username ?? '',
|
||||
log.actor_email ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized),
|
||||
)
|
||||
}, [keyword, logs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32 rounded-3xl" />
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">操作审计</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">后台操作审计日志</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
登录、发布、回滚、订阅推送等关键后台动作都会在这里留下记录,方便排查误操作与安全事件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="按动作 / 对象 / 操作者过滤"
|
||||
className="w-[280px]"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => void loadLogs(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>最近操作</CardTitle>
|
||||
<CardDescription>默认展示最近 120 条关键后台动作。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{filteredLogs.length} 条</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>动作</TableHead>
|
||||
<TableHead>对象</TableHead>
|
||||
<TableHead>操作者</TableHead>
|
||||
<TableHead>补充信息</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-muted-foreground">{log.created_at}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{log.action}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{log.target_type}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{log.target_label ?? log.target_id ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{log.actor_username ?? 'system'}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{log.actor_email ?? log.actor_source ?? '未记录'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[320px] text-sm text-muted-foreground">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-5">
|
||||
{log.metadata ? JSON.stringify(log.metadata, null, 2) : '—'}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1003
admin/src/pages/comments-page.tsx
Normal file
1003
admin/src/pages/comments-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BrainCircuit,
|
||||
Clock3,
|
||||
FolderTree,
|
||||
MessageSquareWarning,
|
||||
RefreshCcw,
|
||||
@@ -24,6 +25,16 @@ import {
|
||||
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({
|
||||
@@ -70,13 +81,13 @@ export function DashboardPage() {
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Dashboard refreshed.')
|
||||
toast.success('仪表盘已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : 'Unable to load dashboard.')
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -102,27 +113,37 @@ export function DashboardPage() {
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: 'Posts',
|
||||
label: '文章总数',
|
||||
value: data.stats.total_posts,
|
||||
note: `${data.stats.total_comments} comments across the content library`,
|
||||
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
||||
icon: Rss,
|
||||
},
|
||||
{
|
||||
label: 'Pending comments',
|
||||
label: '待审核评论',
|
||||
value: data.stats.pending_comments,
|
||||
note: 'Queued for moderation follow-up',
|
||||
note: '等待审核处理',
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
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} tags currently in circulation`,
|
||||
note: `当前共有 ${data.stats.total_tags} 个标签`,
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
label: 'AI chunks',
|
||||
label: 'AI 分块',
|
||||
value: data.stats.ai_chunks,
|
||||
note: data.stats.ai_enabled ? 'Knowledge base is enabled' : 'AI is currently disabled',
|
||||
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
||||
icon: BrainCircuit,
|
||||
},
|
||||
]
|
||||
@@ -131,21 +152,20 @@ export function DashboardPage() {
|
||||
<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">Dashboard</Badge>
|
||||
<Badge variant="secondary">仪表盘</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">Operations overview</h2>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">运营总览</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
This screen pulls the operational signals the old Tera dashboard used to summarize,
|
||||
but now from a standalone React app ready for gradual module migration.
|
||||
这里汇总了最重要的发布、审核和 AI 信号,让日常运营在一个独立后台里完成闭环。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
Open Ask AI
|
||||
打开 AI 问答
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -154,7 +174,7 @@ export function DashboardPage() {
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,21 +189,22 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Recent posts</CardTitle>
|
||||
<CardTitle>最近文章</CardTitle>
|
||||
<CardDescription>
|
||||
Freshly imported or updated content flowing into the public site.
|
||||
最近同步到前台的文章内容。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{data.recent_posts.length} rows</Badge>
|
||||
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -193,13 +214,19 @@ export function DashboardPage() {
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{post.title}</span>
|
||||
{post.pinned ? <Badge variant="success">pinned</Badge> : null}
|
||||
{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">
|
||||
{post.post_type}
|
||||
{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>
|
||||
@@ -212,9 +239,9 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Site heartbeat</CardTitle>
|
||||
<CardTitle>站点状态</CardTitle>
|
||||
<CardDescription>
|
||||
A quick read on the public-facing site and the AI index state.
|
||||
快速查看前台站点与 AI 索引状态。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -225,7 +252,7 @@ export function DashboardPage() {
|
||||
<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 on' : 'AI off'}
|
||||
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +260,7 @@ export function DashboardPage() {
|
||||
<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">
|
||||
Reviews
|
||||
评测
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
||||
@@ -242,7 +269,7 @@ export function DashboardPage() {
|
||||
</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">
|
||||
Friend links
|
||||
友链
|
||||
</p>
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
|
||||
@@ -253,10 +280,38 @@ export function DashboardPage() {
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Last AI index
|
||||
发布队列
|
||||
</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 ?? 'The site has not been indexed yet.'}
|
||||
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -267,21 +322,21 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Pending comments</CardTitle>
|
||||
<CardTitle>待审核评论</CardTitle>
|
||||
<CardDescription>
|
||||
Queue visibility without opening the old moderation page.
|
||||
在当前管理端直接查看审核队列。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_comments.length} queued</Badge>
|
||||
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Author</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Post</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>作者</TableHead>
|
||||
<TableHead>范围</TableHead>
|
||||
<TableHead>文章</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -296,7 +351,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{comment.scope}
|
||||
{formatCommentScope(comment.scope)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{comment.post_slug}
|
||||
@@ -313,12 +368,12 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Pending friend links</CardTitle>
|
||||
<CardTitle>待审核友链</CardTitle>
|
||||
<CardDescription>
|
||||
Requests waiting for review and reciprocal checks.
|
||||
等待审核和互链确认的申请。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} pending</Badge>
|
||||
<Badge variant="warning">{data.pending_friend_links.length} 条待处理</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.pending_friend_links.map((link) => (
|
||||
@@ -335,6 +390,9 @@ export function DashboardPage() {
|
||||
</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>
|
||||
@@ -345,9 +403,9 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent reviews</CardTitle>
|
||||
<CardTitle>最近评测</CardTitle>
|
||||
<CardDescription>
|
||||
The latest review entries flowing into the public reviews page.
|
||||
最近同步到前台评测页的内容。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -359,7 +417,7 @@ export function DashboardPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{review.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{review.review_type} · {review.status}
|
||||
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
455
admin/src/pages/friend-links-page.tsx
Normal file
455
admin/src/pages/friend-links-page.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import { ExternalLink, Link2, RefreshCcw, Save, Trash2 } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { emptyToNull, formatDateTime, formatFriendLinkStatus } from '@/lib/admin-format'
|
||||
import type { FriendLinkPayload, FriendLinkRecord } from '@/lib/types'
|
||||
|
||||
type FriendLinkFormState = {
|
||||
siteName: string
|
||||
siteUrl: string
|
||||
avatarUrl: string
|
||||
description: string
|
||||
category: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const defaultFriendLinkForm: FriendLinkFormState = {
|
||||
siteName: '',
|
||||
siteUrl: '',
|
||||
avatarUrl: '',
|
||||
description: '',
|
||||
category: '',
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
function toFormState(link: FriendLinkRecord): FriendLinkFormState {
|
||||
return {
|
||||
siteName: link.site_name ?? '',
|
||||
siteUrl: link.site_url,
|
||||
avatarUrl: link.avatar_url ?? '',
|
||||
description: link.description ?? '',
|
||||
category: link.category ?? '',
|
||||
status: link.status ?? 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
function toPayload(form: FriendLinkFormState): FriendLinkPayload {
|
||||
return {
|
||||
siteName: emptyToNull(form.siteName),
|
||||
siteUrl: form.siteUrl.trim(),
|
||||
avatarUrl: emptyToNull(form.avatarUrl),
|
||||
description: emptyToNull(form.description),
|
||||
category: emptyToNull(form.category),
|
||||
status: emptyToNull(form.status) ?? 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: string | null) {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'success' as const
|
||||
case 'rejected':
|
||||
return 'danger' as const
|
||||
default:
|
||||
return 'warning' as const
|
||||
}
|
||||
}
|
||||
|
||||
export function FriendLinksPage() {
|
||||
const [links, setLinks] = useState<FriendLinkRecord[]>([])
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<FriendLinkFormState>(defaultFriendLinkForm)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
|
||||
const loadLinks = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.listFriendLinks()
|
||||
startTransition(() => {
|
||||
setLinks(next)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('友链列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载友链列表。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadLinks(false)
|
||||
}, [loadLinks])
|
||||
|
||||
const filteredLinks = useMemo(() => {
|
||||
return links.filter((link) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
[
|
||||
link.site_name ?? '',
|
||||
link.site_url,
|
||||
link.category ?? '',
|
||||
link.description ?? '',
|
||||
link.status ?? '',
|
||||
]
|
||||
.join('\n')
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || (link.status ?? 'pending') === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [links, searchTerm, statusFilter])
|
||||
|
||||
const selectedLink = useMemo(
|
||||
() => links.find((link) => link.id === selectedId) ?? null,
|
||||
[links, selectedId],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">友链</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">友链申请队列</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
审核前台提交的友链申请,维护站点信息,并在待审核、已通过、已拒绝之间完成流转。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedId(null)
|
||||
setForm(defaultFriendLinkForm)
|
||||
}}
|
||||
>
|
||||
新建友链
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => void loadLinks(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>友链列表</CardTitle>
|
||||
<CardDescription>
|
||||
选择一条友链进行编辑,或者直接在右侧创建新记录。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
|
||||
<Input
|
||||
placeholder="按站点名、URL、分类或备注搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-[620px] rounded-3xl" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredLinks.map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(link.id)
|
||||
setForm(toFormState(link))
|
||||
}}
|
||||
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||
selectedId === link.id
|
||||
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{link.site_name ?? '未命名站点'}</span>
|
||||
<Badge variant={statusBadgeVariant(link.status)}>
|
||||
{formatFriendLinkStatus(link.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{link.site_url}</p>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{link.description ?? '暂无简介。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{link.category ?? '未分类'}</p>
|
||||
<p className="mt-1">{formatDateTime(link.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!filteredLinks.length ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
|
||||
<Link2 className="h-8 w-8" />
|
||||
<p>当前筛选条件下没有匹配的友链。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedLink ? '编辑友链' : '新建友链'}</CardTitle>
|
||||
<CardDescription>
|
||||
维护前台友链页依赖的互链地址、分类和审核状态。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedLink ? (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={selectedLink.site_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
访问站点
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedLink ? (
|
||||
<>
|
||||
<Button
|
||||
variant={form.status === 'approved' ? 'default' : 'outline'}
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
variant={form.status === 'pending' ? 'secondary' : 'outline'}
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
|
||||
>
|
||||
待审核
|
||||
</Button>
|
||||
<Button
|
||||
variant={form.status === 'rejected' ? 'danger' : 'outline'}
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!form.siteUrl.trim()) {
|
||||
toast.error('站点 URL 不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const payload = toPayload(form)
|
||||
if (selectedLink) {
|
||||
const updated = await adminApi.updateFriendLink(selectedLink.id, payload)
|
||||
startTransition(() => {
|
||||
setSelectedId(updated.id)
|
||||
setForm(toFormState(updated))
|
||||
})
|
||||
toast.success('友链已更新。')
|
||||
} else {
|
||||
const created = await adminApi.createFriendLink(payload)
|
||||
startTransition(() => {
|
||||
setSelectedId(created.id)
|
||||
setForm(toFormState(created))
|
||||
})
|
||||
toast.success('友链已创建。')
|
||||
}
|
||||
await loadLinks(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法保存友链。')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : selectedLink ? '保存修改' : '创建友链'}
|
||||
</Button>
|
||||
{selectedLink ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('确定删除这条友链吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true)
|
||||
await adminApi.deleteFriendLink(selectedLink.id)
|
||||
toast.success('友链已删除。')
|
||||
setSelectedId(null)
|
||||
setForm(defaultFriendLinkForm)
|
||||
await loadLinks(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法删除友链。')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{selectedLink ? (
|
||||
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
当前记录
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
创建于 {formatDateTime(selectedLink.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(selectedLink.status)}>
|
||||
{formatFriendLinkStatus(selectedLink.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FormField label="站点名称">
|
||||
<Input
|
||||
value={form.siteName}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, siteName: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="站点 URL">
|
||||
<Input
|
||||
value={form.siteUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, siteUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="头像 URL">
|
||||
<Input
|
||||
value={form.avatarUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, avatarUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="分类">
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, category: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="状态">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
form.status === 'pending'
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-700'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">待审核</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">保留在队列里继续观察。</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
form.status === 'approved'
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">通过</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">前台会按已通过友链展示。</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${
|
||||
form.status === 'rejected'
|
||||
? 'border-rose-500/40 bg-rose-500/10 text-rose-700'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">拒绝</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">保留记录,但不在前台展示。</p>
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="简介">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,13 @@ 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')
|
||||
@@ -23,23 +27,22 @@ export function LoginPage({
|
||||
<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 admin
|
||||
Termi 后台
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CardTitle className="text-4xl leading-tight">
|
||||
Separate the dashboard from the public site without losing momentum.
|
||||
将后台从前台中拆分出来,同时保持迭代节奏不掉线。
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xl text-base leading-7">
|
||||
This new workspace is where operations, moderation, and AI controls will migrate
|
||||
out of the old server-rendered admin.
|
||||
当前管理工作统一在这个独立后台中完成,后端专注提供 API、认证与业务规则。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
['React app', 'Independent admin surface'],
|
||||
['shadcn/ui', 'Consistent component foundation'],
|
||||
['Loco API', 'Backend stays focused on data and rules'],
|
||||
['React 应用', '独立后台界面'],
|
||||
['shadcn/ui', '统一的组件基础'],
|
||||
['Loco API', '后端继续专注数据与规则'],
|
||||
].map(([title, description]) => (
|
||||
<div
|
||||
key={title}
|
||||
@@ -58,48 +61,61 @@ export function LoginPage({
|
||||
<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>
|
||||
Sign in to the control room
|
||||
登录管理后台
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The login bridge still uses the current backend admin credentials so we can migrate
|
||||
screens incrementally without stopping delivery.
|
||||
{localLoginEnabled
|
||||
? '当前登录复用后端管理员账号;如果前面接了 TinyAuth / Pocket ID,也可以直接由反向代理完成 SSO。'
|
||||
: proxyAuthEnabled
|
||||
? '当前后台已切到代理侧 SSO 模式,请从受保护的后台域名入口进入。'
|
||||
: '当前后台未开放本地账号密码登录,请检查部署配置。'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onLogin({ username, password })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{localLoginEnabled ? (
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onLogin({ username, password })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" disabled={submitting}>
|
||||
{submitting ? 'Signing in...' : 'Unlock admin'}
|
||||
</Button>
|
||||
</form>
|
||||
<Button className="w-full" size="lg" disabled={submitting}>
|
||||
{submitting ? '登录中...' : '进入后台'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-7 text-muted-foreground">
|
||||
<p>推荐通过 Caddy + TinyAuth + Pocket ID 保护整个后台入口。</p>
|
||||
<p>如果你已经从受保护的后台域名进入,刷新页面后会自动识别当前 SSO 会话。</p>
|
||||
<Button className="w-full" size="lg" onClick={() => window.location.reload()}>
|
||||
重新检查会话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
449
admin/src/pages/media-page.tsx
Normal file
449
admin/src/pages/media-page.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
CheckSquare,
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
RefreshCcw,
|
||||
Replace,
|
||||
Square,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
maybeCompressImageWithPrompt,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = value
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
export function MediaPage() {
|
||||
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null)
|
||||
const [replacingKey, setReplacingKey] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [batchDeleting, setBatchDeleting] = useState(false)
|
||||
const [prefixFilter, setPrefixFilter] = useState('all')
|
||||
const [uploadPrefix, setUploadPrefix] = useState('post-covers/')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [provider, setProvider] = useState<string | null>(null)
|
||||
const [bucket, setBucket] = useState<string | null>(null)
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||
|
||||
const loadItems = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const prefix = prefixFilter === 'all' ? undefined : prefixFilter
|
||||
const result = await adminApi.listMediaObjects({ prefix, limit: 200 })
|
||||
startTransition(() => {
|
||||
setItems(result.items)
|
||||
setProvider(result.provider)
|
||||
setBucket(result.bucket)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('媒体对象列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '媒体对象列表加载失败。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [prefixFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void loadItems(false)
|
||||
}, [loadItems])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedKeys((current) =>
|
||||
current.filter((key) => items.some((item) => item.key === key)),
|
||||
)
|
||||
}, [items])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) => item.key.toLowerCase().includes(keyword))
|
||||
}, [items, searchTerm])
|
||||
|
||||
const allFilteredSelected =
|
||||
filteredItems.length > 0 && filteredItems.every((item) => selectedKeys.includes(item.key))
|
||||
|
||||
async function prepareFiles(files: File[], targetPrefix = uploadPrefix) {
|
||||
if (!compressBeforeUpload) {
|
||||
return files
|
||||
}
|
||||
|
||||
const quality = Number.parseFloat(compressQuality)
|
||||
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
|
||||
const normalizeCover =
|
||||
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
|
||||
|
||||
const result: File[] = []
|
||||
for (const file of files) {
|
||||
const compressed = normalizeCover
|
||||
? await normalizeCoverImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `封面规范化上传(${file.name})`,
|
||||
})
|
||||
: await maybeCompressImageWithPrompt(file, {
|
||||
quality: safeQuality,
|
||||
ask: true,
|
||||
contextLabel: `媒体库上传(${file.name})`,
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
|
||||
}
|
||||
result.push(compressed.file)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">媒体库</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">对象存储媒体管理</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
查看当前对象存储里的封面资源,支持上传、批量删除、压缩上传和原位替换。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadItems(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={!selectedKeys.length || batchDeleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setBatchDeleting(true)
|
||||
const result = await adminApi.batchDeleteMediaObjects(selectedKeys)
|
||||
if (result.failed.length) {
|
||||
toast.warning(`已删除 ${result.deleted.length} 个,失败 ${result.failed.length} 个。`)
|
||||
} else {
|
||||
toast.success(`已删除 ${result.deleted.length} 个对象。`)
|
||||
}
|
||||
await loadItems(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '批量删除失败。')
|
||||
} finally {
|
||||
setBatchDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
批量删除 ({selectedKeys.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>上传与处理</CardTitle>
|
||||
<CardDescription>
|
||||
Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_220px_1fr]">
|
||||
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
|
||||
<option value="all">全部目录</option>
|
||||
<option value="post-covers/">文章封面</option>
|
||||
<option value="review-covers/">评测封面</option>
|
||||
<option value="uploads/">通用上传</option>
|
||||
</Select>
|
||||
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
|
||||
<option value="post-covers/">上传到文章封面</option>
|
||||
<option value="review-covers/">上传到评测封面</option>
|
||||
<option value="uploads/">上传到通用目录</option>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="按对象 key 搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
|
||||
<Input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || [])
|
||||
setUploadFiles(files)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCompressBeforeUpload((current) => !current)}
|
||||
>
|
||||
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||||
压缩上传
|
||||
</Button>
|
||||
<Input
|
||||
className="w-[96px]"
|
||||
value={compressQuality}
|
||||
onChange={(event) => setCompressQuality(event.target.value)}
|
||||
placeholder="0.82"
|
||||
disabled={!compressBeforeUpload}
|
||||
/>
|
||||
<Button
|
||||
disabled={!uploadFiles.length || uploading}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setUploading(true)
|
||||
const files = await prepareFiles(uploadFiles)
|
||||
const result = await adminApi.uploadMediaObjects(files, {
|
||||
prefix: uploadPrefix,
|
||||
})
|
||||
toast.success(`上传完成,共 ${result.uploaded.length} 个文件。`)
|
||||
setUploadFiles([])
|
||||
await loadItems(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '上传失败。')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploading ? '上传中...' : '上传'}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadFiles.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {uploadFiles.length} 个文件。
|
||||
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
|
||||
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredItems.map((item, index) => {
|
||||
const selected = selectedKeys.includes(item.key)
|
||||
const replaceInputId = `replace-media-${index}`
|
||||
|
||||
return (
|
||||
<Card key={item.key} className="overflow-hidden">
|
||||
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
|
||||
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-2 rounded-xl border border-border/80 bg-background/80 p-1"
|
||||
onClick={() => {
|
||||
setSelectedKeys((current) => {
|
||||
if (current.includes(item.key)) {
|
||||
return current.filter((key) => key !== item.key)
|
||||
}
|
||||
return [...current, item.key]
|
||||
})
|
||||
}}
|
||||
>
|
||||
{selected ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="space-y-2">
|
||||
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatBytes(item.size_bytes)}</span>
|
||||
{item.last_modified ? <span>{item.last_modified}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.url)
|
||||
toast.success('图片链接已复制。')
|
||||
} catch {
|
||||
toast.error('复制失败,请手动复制。')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<label htmlFor={replaceInputId} className="cursor-pointer">
|
||||
<Replace className="h-4 w-4" />
|
||||
替换
|
||||
</label>
|
||||
</Button>
|
||||
<input
|
||||
id={replaceInputId}
|
||||
className="hidden"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.item(0)
|
||||
event.currentTarget.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setReplacingKey(item.key)
|
||||
const [prepared] = await prepareFiles(
|
||||
[file],
|
||||
item.key.startsWith('review-covers/')
|
||||
? 'review-covers/'
|
||||
: item.key.startsWith('post-covers/')
|
||||
? 'post-covers/'
|
||||
: 'uploads/',
|
||||
)
|
||||
const result = await adminApi.replaceMediaObject(item.key, prepared)
|
||||
startTransition(() => {
|
||||
setItems((current) =>
|
||||
current.map((currentItem) =>
|
||||
currentItem.key === item.key
|
||||
? { ...currentItem, url: result.url }
|
||||
: currentItem,
|
||||
),
|
||||
)
|
||||
})
|
||||
toast.success('已替换媒体对象。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '替换失败。')
|
||||
} finally {
|
||||
setReplacingKey(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deletingKey === item.key || replacingKey === item.key}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setDeletingKey(item.key)
|
||||
await adminApi.deleteMediaObject(item.key)
|
||||
startTransition(() => {
|
||||
setItems((current) =>
|
||||
current.filter((currentItem) => currentItem.key !== item.key),
|
||||
)
|
||||
setSelectedKeys((current) =>
|
||||
current.filter((key) => key !== item.key),
|
||||
)
|
||||
})
|
||||
toast.success('媒体对象已删除。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
|
||||
} finally {
|
||||
setDeletingKey(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deletingKey === item.key
|
||||
? '删除中...'
|
||||
: replacingKey === item.key
|
||||
? '替换中...'
|
||||
: '删除'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{!filteredItems.length ? (
|
||||
<Card className="xl:col-span-2 2xl:col-span-3">
|
||||
<CardContent className="flex flex-col items-center gap-3 px-6 py-16 text-center text-muted-foreground">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<p>当前筛选条件下没有媒体对象。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6 text-sm text-muted-foreground">
|
||||
<p>
|
||||
当前筛选 {filteredItems.length} 个对象,已选择 {selectedKeys.length} 个。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (allFilteredSelected) {
|
||||
setSelectedKeys((current) =>
|
||||
current.filter(
|
||||
(key) => !filteredItems.some((item) => item.key === key),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
setSelectedKeys((current) => {
|
||||
const next = new Set(current)
|
||||
filteredItems.forEach((item) => next.add(item.key))
|
||||
return Array.from(next)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{allFilteredSelected ? <Square className="h-4 w-4" /> : <CheckSquare className="h-4 w-4" />}
|
||||
{allFilteredSelected ? '取消全选' : '全选当前筛选'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
admin/src/pages/post-compare-page.tsx
Normal file
166
admin/src/pages/post-compare-page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { countLineDiff } from '@/lib/markdown-diff'
|
||||
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
|
||||
|
||||
type CompareState = {
|
||||
title: string
|
||||
slug: string
|
||||
path: string
|
||||
savedMarkdown: string
|
||||
draftMarkdown: string
|
||||
}
|
||||
|
||||
function resolveSlugFromPathname() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/compare\/?$/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('draftKey')
|
||||
}
|
||||
|
||||
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const [state, setState] = useState<CompareState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
const [post, markdown] = await Promise.all([
|
||||
adminApi.getPostBySlug(slug),
|
||||
adminApi.getPostMarkdown(slug),
|
||||
])
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: post.title ?? slug,
|
||||
slug,
|
||||
path: markdown.path,
|
||||
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
|
||||
draftMarkdown: draft?.markdown ?? markdown.markdown,
|
||||
})
|
||||
})
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setError(loadError instanceof ApiError ? loadError.message : '无法加载改动对比。')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
const diffStats = useMemo(() => {
|
||||
if (!state) {
|
||||
return { additions: 0, deletions: 0 }
|
||||
}
|
||||
|
||||
return countLineDiff(state.savedMarkdown, state.draftMarkdown)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||
<div className="mx-auto max-w-[1480px] space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">独立对比窗口</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{state?.title || '草稿改动对比'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
左侧是当前已保存的正文,右侧是你正在编辑的草稿。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="success">+{diffStats.additions} 行</Badge>
|
||||
<Badge variant="danger">-{diffStats.deletions} 行</Badge>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">正在加载改动内容...</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>改动对比加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : state ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GitCompareArrows className="h-4 w-4" />
|
||||
保存版本 vs 当前草稿
|
||||
</CardTitle>
|
||||
<CardDescription>{state.path}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<MarkdownWorkbench
|
||||
value={state.draftMarkdown}
|
||||
originalValue={state.savedMarkdown}
|
||||
path={state.path}
|
||||
mode="workspace"
|
||||
visiblePanels={['diff']}
|
||||
availablePanels={['diff']}
|
||||
readOnly
|
||||
preview={<></>}
|
||||
originalLabel="已保存版本"
|
||||
modifiedLabel="当前草稿"
|
||||
onModeChange={() => {}}
|
||||
onVisiblePanelsChange={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
admin/src/pages/post-polish-page.tsx
Normal file
302
admin/src/pages/post-polish-page.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
configureMonaco,
|
||||
editorTheme,
|
||||
sharedOptions,
|
||||
} from '@/components/markdown-workbench'
|
||||
import { LazyDiffEditor } from '@/components/lazy-monaco'
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { computeDiffHunks, applySelectedDiffHunks } from '@/lib/markdown-merge'
|
||||
import {
|
||||
loadDraftWindowSnapshot,
|
||||
savePolishWindowResult,
|
||||
type DraftWindowSnapshot,
|
||||
} from '@/lib/post-draft-window'
|
||||
|
||||
type PolishTarget = 'editor' | 'create'
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('draftKey')
|
||||
}
|
||||
|
||||
function getTarget(): PolishTarget {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'editor'
|
||||
}
|
||||
|
||||
const value = new URLSearchParams(window.location.search).get('target')
|
||||
return value === 'create' ? 'create' : 'editor'
|
||||
}
|
||||
|
||||
function buildApplyMessage(draftKey: string, markdown: string, target: PolishTarget) {
|
||||
return {
|
||||
type: 'termi-admin-post-polish-apply',
|
||||
draftKey,
|
||||
markdown,
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
export function PostPolishPage() {
|
||||
const draftKey = getDraftKey()
|
||||
const target = getTarget()
|
||||
const [snapshot, setSnapshot] = useState<DraftWindowSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [polishing, setPolishing] = useState(false)
|
||||
const [polishedMarkdown, setPolishedMarkdown] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraftWindowSnapshot(draftKey)
|
||||
if (!draft) {
|
||||
setError('没有找到要润色的草稿快照,请从文章编辑页重新打开 AI 润色窗口。')
|
||||
} else {
|
||||
startTransition(() => {
|
||||
setSnapshot(draft)
|
||||
})
|
||||
}
|
||||
setLoading(false)
|
||||
}, [draftKey])
|
||||
|
||||
const originalMarkdown = snapshot?.markdown ?? ''
|
||||
const hunks = useMemo(
|
||||
() => (polishedMarkdown ? computeDiffHunks(originalMarkdown, polishedMarkdown) : []),
|
||||
[originalMarkdown, polishedMarkdown],
|
||||
)
|
||||
const mergedMarkdown = useMemo(
|
||||
() => applySelectedDiffHunks(originalMarkdown, hunks, selectedIds),
|
||||
[hunks, originalMarkdown, selectedIds],
|
||||
)
|
||||
|
||||
const applyAll = () => {
|
||||
setSelectedIds(new Set(hunks.map((hunk) => hunk.id)))
|
||||
}
|
||||
|
||||
const keepOriginal = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const applyToParent = () => {
|
||||
if (!draftKey) {
|
||||
toast.error('当前窗口缺少草稿标识,无法回填。')
|
||||
return
|
||||
}
|
||||
|
||||
const result = savePolishWindowResult(draftKey, mergedMarkdown, target)
|
||||
window.opener?.postMessage(buildApplyMessage(draftKey, mergedMarkdown, target), window.location.origin)
|
||||
toast.success('已把 AI 润色结果回填到原编辑器。')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||
<div className="mx-auto max-w-[1560px] space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">AI 润色工作台</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{snapshot?.title || 'AI 润色与选择性合并'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
左边保留润色前的原稿,右边是当前选中的合并结果。你可以先生成 AI 润色稿,再按改动块决定要保留哪些内容。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!snapshot || polishing}
|
||||
onClick={async () => {
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setPolishing(true)
|
||||
const result = await adminApi.polishPostMarkdown(snapshot.markdown)
|
||||
const nextHunks = computeDiffHunks(snapshot.markdown, result.polished_markdown)
|
||||
startTransition(() => {
|
||||
setPolishedMarkdown(result.polished_markdown)
|
||||
setSelectedIds(new Set(nextHunks.map((hunk) => hunk.id)))
|
||||
})
|
||||
toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`)
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : 'AI 润色失败。')
|
||||
} finally {
|
||||
setPolishing(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{polishing ? '润色中...' : polishedMarkdown ? '重新生成润色稿' : '生成 AI 润色稿'}
|
||||
</Button>
|
||||
<Button variant="outline" disabled={!hunks.length} onClick={applyAll}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
全部采用
|
||||
</Button>
|
||||
<Button variant="outline" disabled={!hunks.length} onClick={keepOriginal}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
全部还原
|
||||
</Button>
|
||||
<Button disabled={!hunks.length} onClick={applyToParent}>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
应用到原编辑器
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">正在加载草稿快照...</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI 润色窗口加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : snapshot ? (
|
||||
<div className="grid gap-6 xl:grid-cols-[1.14fr_0.86fr]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>润色前 vs 当前合并结果</CardTitle>
|
||||
<CardDescription>{snapshot.path}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="secondary">改动块 {hunks.length}</Badge>
|
||||
<Badge variant="success">已采用 {selectedIds.size}</Badge>
|
||||
<Badge variant="outline">目标 {target === 'create' ? '新建草稿' : '现有文章'}</Badge>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e]">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
|
||||
<span>润色前原稿</span>
|
||||
<span>当前合并结果</span>
|
||||
</div>
|
||||
<div className="h-[560px]">
|
||||
<LazyDiffEditor
|
||||
height="100%"
|
||||
language="markdown"
|
||||
original={originalMarkdown}
|
||||
modified={mergedMarkdown}
|
||||
originalModelPath={`${snapshot.path}#ai-original`}
|
||||
modifiedModelPath={`${snapshot.path}#ai-merged`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
theme={editorTheme}
|
||||
beforeMount={configureMonaco}
|
||||
options={{
|
||||
...sharedOptions,
|
||||
originalEditable: false,
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前合并结果预览</CardTitle>
|
||||
<CardDescription>边挑选改动,边查看最终会保存成什么样。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[420px] overflow-hidden rounded-[28px] border border-slate-200 bg-white">
|
||||
<MarkdownPreview markdown={mergedMarkdown || originalMarkdown} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>改动块选择</CardTitle>
|
||||
<CardDescription>
|
||||
每一块都可以单独采用或保留原文,合并结果会立即同步到右侧 diff 和预览。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!polishedMarkdown ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/70 px-5 py-10 text-sm text-muted-foreground">
|
||||
先点“生成 AI 润色稿”,这里才会出现可选的改动块。
|
||||
</div>
|
||||
) : hunks.length ? (
|
||||
hunks.map((hunk, index) => {
|
||||
const accepted = selectedIds.has(hunk.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hunk.id}
|
||||
className={`rounded-3xl border p-4 transition ${
|
||||
accepted
|
||||
? 'border-emerald-500/30 bg-emerald-500/10'
|
||||
: 'border-border/70 bg-background/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">改动块 {index + 1}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
原文 {hunk.originalStart}-{Math.max(hunk.originalEnd, hunk.originalStart - 1)} 行
|
||||
,润色稿 {hunk.modifiedStart}-{Math.max(hunk.modifiedEnd, hunk.modifiedStart - 1)} 行
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={accepted ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(hunk.id)) {
|
||||
next.delete(hunk.id)
|
||||
} else {
|
||||
next.add(hunk.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
{accepted ? '已采用' : '采用这块'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 rounded-2xl border border-border/60 bg-background/70 px-3 py-2 text-xs leading-6 text-muted-foreground">
|
||||
{hunk.preview}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-3xl border border-border/70 px-5 py-10 text-sm text-muted-foreground">
|
||||
AI 已返回结果,但没有检测到行级差异。可以直接应用,或者重新生成一次。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
admin/src/pages/post-preview-page.tsx
Normal file
166
admin/src/pages/post-preview-page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ExternalLink, RefreshCcw } from 'lucide-react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
|
||||
import { MarkdownPreview } from '@/components/markdown-preview'
|
||||
import { MarkdownWorkbench } from '@/components/markdown-workbench'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
|
||||
|
||||
type PreviewState = {
|
||||
title: string
|
||||
slug: string
|
||||
path: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
function resolveSlugFromPathname() {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function getDraftKey() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
return new URLSearchParams(window.location.search).get('draftKey')
|
||||
}
|
||||
|
||||
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
const slug = slugOverride ?? resolveSlugFromPathname()
|
||||
const [state, setState] = useState<PreviewState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const draft = loadDraftWindowSnapshot(getDraftKey())
|
||||
|
||||
if (draft && draft.slug === slug) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: draft.title,
|
||||
slug: draft.slug,
|
||||
path: draft.path,
|
||||
markdown: draft.markdown,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const [post, markdown] = await Promise.all([
|
||||
adminApi.getPostBySlug(slug),
|
||||
adminApi.getPostMarkdown(slug),
|
||||
])
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setState({
|
||||
title: post.title ?? slug,
|
||||
slug,
|
||||
path: markdown.path,
|
||||
markdown: markdown.markdown,
|
||||
})
|
||||
})
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
setError(loadError instanceof ApiError ? loadError.message : '无法加载预览内容。')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
|
||||
<div className="mx-auto max-w-[1400px] space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">独立预览窗口</Badge>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{state?.title || '文章预览'}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
这里展示的是当前草稿的渲染效果,不会打断主编辑器里的输入位置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
重新加载
|
||||
</Button>
|
||||
{slug ? (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={buildFrontendUrl(`/articles/${slug}`)} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开前台页面
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-sm text-muted-foreground">正在加载预览内容...</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>预览加载失败</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : state ? (
|
||||
<MarkdownWorkbench
|
||||
value={state.markdown}
|
||||
originalValue=""
|
||||
path={state.path}
|
||||
mode="workspace"
|
||||
visiblePanels={['preview']}
|
||||
availablePanels={['preview']}
|
||||
readOnly
|
||||
preview={<MarkdownPreview markdown={state.markdown} />}
|
||||
onModeChange={() => {}}
|
||||
onVisiblePanelsChange={() => {}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3233
admin/src/pages/posts-page.tsx
Normal file
3233
admin/src/pages/posts-page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
688
admin/src/pages/reviews-page.tsx
Normal file
688
admin/src/pages/reviews-page.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import {
|
||||
csvToList,
|
||||
formatDateTime,
|
||||
formatReviewStatus,
|
||||
formatReviewType,
|
||||
reviewTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||
|
||||
type ReviewFormState = {
|
||||
title: string
|
||||
reviewType: string
|
||||
rating: string
|
||||
reviewDate: string
|
||||
status: string
|
||||
description: string
|
||||
tags: string
|
||||
cover: string
|
||||
linkUrl: string
|
||||
}
|
||||
|
||||
type ReviewDescriptionPolishState = {
|
||||
originalDescription: string
|
||||
polishedDescription: string
|
||||
}
|
||||
|
||||
const defaultReviewForm: ReviewFormState = {
|
||||
title: '',
|
||||
reviewType: 'book',
|
||||
rating: '4',
|
||||
reviewDate: '',
|
||||
status: 'published',
|
||||
description: '',
|
||||
tags: '',
|
||||
cover: '',
|
||||
linkUrl: '',
|
||||
}
|
||||
|
||||
function toFormState(review: ReviewRecord): ReviewFormState {
|
||||
return {
|
||||
title: review.title ?? '',
|
||||
reviewType: review.review_type ?? 'book',
|
||||
rating: String(review.rating ?? 4),
|
||||
reviewDate: review.review_date ?? '',
|
||||
status: review.status ?? 'published',
|
||||
description: review.description ?? '',
|
||||
tags: reviewTagsToList(review.tags).join(', '),
|
||||
cover: review.cover ?? '',
|
||||
linkUrl: review.link_url ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function toCreatePayload(form: ReviewFormState): CreateReviewPayload {
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
review_type: form.reviewType,
|
||||
rating: Number(form.rating),
|
||||
review_date: form.reviewDate,
|
||||
status: form.status,
|
||||
description: form.description.trim(),
|
||||
tags: csvToList(form.tags),
|
||||
cover: form.cover.trim(),
|
||||
link_url: form.linkUrl.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
function toUpdatePayload(form: ReviewFormState): UpdateReviewPayload {
|
||||
return {
|
||||
title: form.title.trim(),
|
||||
review_type: form.reviewType,
|
||||
rating: Number(form.rating),
|
||||
review_date: form.reviewDate,
|
||||
status: form.status,
|
||||
description: form.description.trim(),
|
||||
tags: csvToList(form.tags),
|
||||
cover: form.cover.trim(),
|
||||
link_url: form.linkUrl.trim() || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function ReviewsPage() {
|
||||
const [reviews, setReviews] = useState<ReviewRecord[]>([])
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<ReviewFormState>(defaultReviewForm)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [polishingDescription, setPolishingDescription] = useState(false)
|
||||
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
|
||||
null,
|
||||
)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const loadReviews = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.listReviews()
|
||||
startTransition(() => {
|
||||
setReviews(next)
|
||||
})
|
||||
|
||||
if (showToast) {
|
||||
toast.success('评测列表已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载评测列表。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadReviews(false)
|
||||
}, [loadReviews])
|
||||
|
||||
const filteredReviews = useMemo(() => {
|
||||
return reviews.filter((review) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
[
|
||||
review.title ?? '',
|
||||
review.review_type ?? '',
|
||||
review.description ?? '',
|
||||
review.tags ?? '',
|
||||
review.status ?? '',
|
||||
]
|
||||
.join('\n')
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' || (review.status ?? 'published') === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [reviews, searchTerm, statusFilter])
|
||||
|
||||
const selectedReview = useMemo(
|
||||
() => reviews.find((review) => review.id === selectedId) ?? null,
|
||||
[reviews, selectedId],
|
||||
)
|
||||
|
||||
const requestDescriptionPolish = useCallback(async () => {
|
||||
if (!form.description.trim()) {
|
||||
toast.error('请先写一点点评内容,再让 AI 帮你润色。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setPolishingDescription(true)
|
||||
const result = await adminApi.polishReviewDescription({
|
||||
title: form.title.trim() || '未命名评测',
|
||||
reviewType: form.reviewType,
|
||||
rating: Number(form.rating) || 0,
|
||||
reviewDate: form.reviewDate || null,
|
||||
status: form.status,
|
||||
tags: csvToList(form.tags),
|
||||
description: form.description,
|
||||
})
|
||||
const polishedDescription =
|
||||
typeof result.polished_description === 'string' ? result.polished_description : ''
|
||||
|
||||
if (!polishedDescription.trim()) {
|
||||
throw new Error('AI 润色返回为空。')
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setDescriptionPolish({
|
||||
originalDescription: form.description,
|
||||
polishedDescription,
|
||||
})
|
||||
})
|
||||
|
||||
if (polishedDescription.trim() === form.description.trim()) {
|
||||
toast.success('AI 已检查这段点评,当前文案已经比较完整。')
|
||||
} else {
|
||||
toast.success('AI 已生成一版更顺的点评文案,可以先对比再决定是否采用。')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'AI 润色点评失败。',
|
||||
)
|
||||
} finally {
|
||||
setPolishingDescription(false)
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const uploadReviewCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '评测封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
const result = await adminApi.uploadReviewCoverImage(compressed.file)
|
||||
startTransition(() => {
|
||||
setForm((current) => ({ ...current, cover: result.url }))
|
||||
})
|
||||
toast.success('评测封面已上传到 R2。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">评测</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">评测内容库</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
管理前台评测页依赖的评测目录,包括评分、媒介类型、封面和发布状态。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedId(null)
|
||||
setForm(defaultReviewForm)
|
||||
setDescriptionPolish(null)
|
||||
}}
|
||||
>
|
||||
新建评测
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => void loadReviews(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>评测列表</CardTitle>
|
||||
<CardDescription>
|
||||
选择已有评测进行编辑,或者在右侧直接创建新条目。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
|
||||
<Input
|
||||
placeholder="按标题、媒介、简介、标签或状态搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="archived">已归档</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-[620px] rounded-3xl" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredReviews.map((review) => (
|
||||
<button
|
||||
key={review.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(review.id)
|
||||
setForm(toFormState(review))
|
||||
setDescriptionPolish(null)
|
||||
}}
|
||||
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||
selectedId === review.id
|
||||
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
|
||||
: 'border-border/70 bg-background/60 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{review.title ?? '未命名评测'}</span>
|
||||
<Badge variant="outline">{formatReviewType(review.review_type)}</Badge>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{review.description ?? '暂无简介。'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{reviewTagsToList(review.tags).join(', ') || '暂无标签'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-semibold">{review.rating ?? 0}/5</div>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{formatReviewStatus(review.status)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formatDateTime(review.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!filteredReviews.length ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
|
||||
<BookOpenText className="h-8 w-8" />
|
||||
<p>当前筛选条件下没有匹配的评测。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>{selectedReview ? '编辑评测' : '新建评测'}</CardTitle>
|
||||
<CardDescription>
|
||||
维护前台评测页直接读取的展示字段。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!form.title.trim()) {
|
||||
toast.error('标题不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.reviewDate) {
|
||||
toast.error('评测日期不能为空。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
if (selectedReview) {
|
||||
const updated = await adminApi.updateReview(
|
||||
selectedReview.id,
|
||||
toUpdatePayload(form),
|
||||
)
|
||||
startTransition(() => {
|
||||
setSelectedId(updated.id)
|
||||
setForm(toFormState(updated))
|
||||
setDescriptionPolish(null)
|
||||
})
|
||||
toast.success('评测已更新。')
|
||||
} else {
|
||||
const created = await adminApi.createReview(toCreatePayload(form))
|
||||
startTransition(() => {
|
||||
setSelectedId(created.id)
|
||||
setForm(toFormState(created))
|
||||
setDescriptionPolish(null)
|
||||
})
|
||||
toast.success('评测已创建。')
|
||||
}
|
||||
await loadReviews(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法保存评测。')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : selectedReview ? '保存修改' : '创建评测'}
|
||||
</Button>
|
||||
{selectedReview ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('确定删除这条评测吗?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true)
|
||||
await adminApi.deleteReview(selectedReview.id)
|
||||
toast.success('评测已删除。')
|
||||
setSelectedId(null)
|
||||
setForm(defaultReviewForm)
|
||||
setDescriptionPolish(null)
|
||||
await loadReviews(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{selectedReview ? (
|
||||
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
当前记录
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
创建于 {formatDateTime(selectedReview.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FormField label="标题">
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, title: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="评测类型">
|
||||
<Select
|
||||
value={form.reviewType}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, reviewType: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="book">图书</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="game">游戏</option>
|
||||
<option value="anime">动画</option>
|
||||
<option value="music">音乐</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="评分">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={form.rating}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, rating: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="评测日期">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.reviewDate}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, reviewDate: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="状态">
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="archived">已归档</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={form.cover}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, cover: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={reviewCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadReviewCover(file)
|
||||
}
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={uploadingCover}
|
||||
onClick={() => reviewCoverInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingCover ? '上传中...' : '上传到 R2'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{form.cover ? (
|
||||
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">
|
||||
<img
|
||||
src={form.cover}
|
||||
alt={form.title || '评测封面预览'}
|
||||
className="h-48 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
|
||||
<Input
|
||||
type="url"
|
||||
value={form.linkUrl}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, linkUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
|
||||
<Input
|
||||
value={form.tags}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, tags: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<FormField
|
||||
label="简介 / 点评"
|
||||
hint="可以先写你的原始观感,再用 AI 帮你把这段点评润得更顺。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
AI 会结合当前标题、类型、评分、状态和标签,只润色这段点评文案,不会自动保存。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void requestDescriptionPolish()}
|
||||
disabled={polishingDescription}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
{polishingDescription ? '润色中...' : 'AI 润色点评'}
|
||||
</Button>
|
||||
{descriptionPolish ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDescriptionPolish(null)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
收起对比
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) => {
|
||||
const nextDescription = event.target.value
|
||||
setForm((current) => ({ ...current, description: nextDescription }))
|
||||
setDescriptionPolish((current) =>
|
||||
current && current.originalDescription === nextDescription ? current : null,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{descriptionPolish ? (
|
||||
<div className="overflow-hidden rounded-[1.8rem] border border-border/70 bg-background/80">
|
||||
<div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-base font-semibold">AI 点评润色对比</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
左边保留当前文案,右边是 AI 建议,你可以直接采用或保留原文。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
description: descriptionPolish.polishedDescription,
|
||||
}))
|
||||
setDescriptionPolish(null)
|
||||
toast.success('AI 润色点评已回填到评测简介。')
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
采用润色结果
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void requestDescriptionPolish()}
|
||||
disabled={polishingDescription}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
重新润色
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDescriptionPolish(null)}
|
||||
>
|
||||
保留原文
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5 xl:grid-cols-2">
|
||||
<div className="rounded-[1.4rem] border border-border/70 bg-muted/20 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
当前点评
|
||||
</p>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
|
||||
{descriptionPolish.originalDescription.trim() || '未填写'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[1.4rem] border border-emerald-500/30 bg-emerald-500/5 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
AI 建议
|
||||
</p>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-7">
|
||||
{descriptionPolish.polishedDescription.trim() || '未填写'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
420
admin/src/pages/revisions-page.tsx
Normal file
420
admin/src/pages/revisions-page.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { ArrowLeftRight, History, RefreshCcw, RotateCcw } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { countLineDiff } from '@/lib/markdown-diff'
|
||||
import { parseMarkdownDocument } from '@/lib/markdown-document'
|
||||
import type { PostRevisionDetail, PostRevisionRecord } from '@/lib/types'
|
||||
|
||||
type RestoreMode = 'full' | 'markdown' | 'metadata'
|
||||
|
||||
const META_LABELS: Record<string, string> = {
|
||||
title: '标题',
|
||||
slug: 'Slug',
|
||||
description: '摘要',
|
||||
category: '分类',
|
||||
postType: '类型',
|
||||
image: '封面',
|
||||
images: '图片集',
|
||||
pinned: '置顶',
|
||||
status: '状态',
|
||||
visibility: '可见性',
|
||||
publishAt: '定时发布',
|
||||
unpublishAt: '下线时间',
|
||||
canonicalUrl: 'Canonical',
|
||||
noindex: 'Noindex',
|
||||
ogImage: 'OG 图',
|
||||
redirectFrom: '旧地址',
|
||||
redirectTo: '重定向',
|
||||
tags: '标签',
|
||||
}
|
||||
|
||||
function stableValue(value: unknown) {
|
||||
if (Array.isArray(value) || (value && typeof value === 'object')) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value ?? '')
|
||||
}
|
||||
|
||||
function summarizeMetadataChanges(leftMarkdown: string, rightMarkdown: string) {
|
||||
const left = parseMarkdownDocument(leftMarkdown).meta
|
||||
const right = parseMarkdownDocument(rightMarkdown).meta
|
||||
|
||||
return Object.entries(META_LABELS)
|
||||
.filter(([key]) => stableValue(left[key as keyof typeof left]) !== stableValue(right[key as keyof typeof right]))
|
||||
.map(([, label]) => label)
|
||||
}
|
||||
|
||||
export function RevisionsPage() {
|
||||
const [revisions, setRevisions] = useState<PostRevisionRecord[]>([])
|
||||
const [selected, setSelected] = useState<PostRevisionDetail | null>(null)
|
||||
const [detailsCache, setDetailsCache] = useState<Record<number, PostRevisionDetail>>({})
|
||||
const [liveMarkdown, setLiveMarkdown] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [restoring, setRestoring] = useState<string | null>(null)
|
||||
const [slugFilter, setSlugFilter] = useState('')
|
||||
const [compareTarget, setCompareTarget] = useState('current')
|
||||
|
||||
const loadRevisions = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const next = await adminApi.listPostRevisions({
|
||||
slug: slugFilter.trim() || undefined,
|
||||
limit: 120,
|
||||
})
|
||||
startTransition(() => {
|
||||
setRevisions(next)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('版本历史已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载版本历史。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [slugFilter])
|
||||
|
||||
useEffect(() => {
|
||||
void loadRevisions(false)
|
||||
}, [loadRevisions])
|
||||
|
||||
const openDetail = useCallback(async (id: number) => {
|
||||
try {
|
||||
const detail = detailsCache[id] ?? (await adminApi.getPostRevision(id))
|
||||
let liveMarkdownValue = ''
|
||||
try {
|
||||
const live = await adminApi.getPostMarkdown(detail.item.post_slug)
|
||||
liveMarkdownValue = live.markdown
|
||||
} catch {
|
||||
liveMarkdownValue = ''
|
||||
}
|
||||
startTransition(() => {
|
||||
setDetailsCache((current) => ({ ...current, [id]: detail }))
|
||||
setSelected(detail)
|
||||
setLiveMarkdown(liveMarkdownValue)
|
||||
setCompareTarget('current')
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载该版本详情。')
|
||||
}
|
||||
}, [detailsCache])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
|
||||
if (compareTarget === 'current' || !compareTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const revisionId = Number(compareTarget)
|
||||
if (!Number.isFinite(revisionId) || detailsCache[revisionId]) {
|
||||
return
|
||||
}
|
||||
|
||||
void adminApi
|
||||
.getPostRevision(revisionId)
|
||||
.then((detail) => {
|
||||
startTransition(() => {
|
||||
setDetailsCache((current) => ({ ...current, [revisionId]: detail }))
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载比较版本。')
|
||||
})
|
||||
}, [compareTarget, detailsCache, selected])
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const uniqueSlugs = new Set(revisions.map((item) => item.post_slug))
|
||||
return {
|
||||
count: revisions.length,
|
||||
slugs: uniqueSlugs.size,
|
||||
}
|
||||
}, [revisions])
|
||||
|
||||
const compareCandidates = useMemo(
|
||||
() =>
|
||||
selected
|
||||
? revisions.filter((item) => item.post_slug === selected.item.post_slug && item.id !== selected.item.id)
|
||||
: [],
|
||||
[revisions, selected],
|
||||
)
|
||||
|
||||
const comparisonMarkdown = useMemo(() => {
|
||||
if (!selected) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (compareTarget === 'current') {
|
||||
return liveMarkdown
|
||||
}
|
||||
|
||||
const revisionId = Number(compareTarget)
|
||||
return Number.isFinite(revisionId) ? detailsCache[revisionId]?.markdown ?? '' : ''
|
||||
}, [compareTarget, detailsCache, liveMarkdown, selected])
|
||||
|
||||
const comparisonLabel = useMemo(() => {
|
||||
if (compareTarget === 'current') {
|
||||
return '当前线上版本'
|
||||
}
|
||||
|
||||
const revisionId = Number(compareTarget)
|
||||
const detail = Number.isFinite(revisionId) ? detailsCache[revisionId] : undefined
|
||||
return detail ? `版本 #${detail.item.id}` : '比较版本'
|
||||
}, [compareTarget, detailsCache])
|
||||
|
||||
const diffStats = useMemo(() => {
|
||||
if (!selected || !comparisonMarkdown) {
|
||||
return { additions: 0, deletions: 0 }
|
||||
}
|
||||
return countLineDiff(comparisonMarkdown, selected.markdown ?? '')
|
||||
}, [comparisonMarkdown, selected])
|
||||
|
||||
const metadataChanges = useMemo(() => {
|
||||
if (!selected || !comparisonMarkdown) {
|
||||
return [] as string[]
|
||||
}
|
||||
return summarizeMetadataChanges(comparisonMarkdown, selected.markdown ?? '')
|
||||
}, [comparisonMarkdown, selected])
|
||||
|
||||
const runRestore = useCallback(
|
||||
async (mode: RestoreMode) => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setRestoring(`${selected.item.id}:${mode}`)
|
||||
await adminApi.restorePostRevision(selected.item.id, mode)
|
||||
toast.success(`已按 ${mode} 模式回滚到版本 #${selected.item.id}`)
|
||||
await loadRevisions(false)
|
||||
await openDetail(selected.item.id)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '恢复版本失败。')
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
},
|
||||
[loadRevisions, openDetail, selected],
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32 rounded-3xl" />
|
||||
<Skeleton className="h-[580px] rounded-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">版本历史</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">文章版本快照、Diff 与局部回滚</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
每次保存、导入、删除、恢复前后都会留下一份 Markdown 快照。现在支持比较当前线上版本或任意历史版本,并可选择 full / markdown / metadata 三种恢复模式。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
value={slugFilter}
|
||||
onChange={(event) => setSlugFilter(event.target.value)}
|
||||
placeholder="按 slug 过滤,例如 hello-world"
|
||||
className="w-[280px]"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => void loadRevisions(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.12fr_0.88fr]">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>快照列表</CardTitle>
|
||||
<CardDescription>
|
||||
当前共 {summary.count} 条快照,覆盖 {summary.slugs} 篇文章。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{summary.count}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文章</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>操作者</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{revisions.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{item.post_title ?? item.post_slug}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{item.post_slug}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary">{item.operation}</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.revision_reason ?? '自动记录'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.actor_username ?? item.actor_email ?? 'system'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.created_at}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
|
||||
<History className="h-4 w-4" />
|
||||
查看 / 对比
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前选中版本</CardTitle>
|
||||
<CardDescription>支持查看快照、对比当前线上版本或另一历史版本,并按不同模式回滚。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{selected.item.operation}</Badge>
|
||||
<Badge variant="outline">#{selected.item.id}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 font-medium">{selected.item.post_title ?? selected.item.post_slug}</p>
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{selected.item.post_slug} · {selected.item.created_at}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<LabelRow title="比较基线" />
|
||||
<Select value={compareTarget} onChange={(event) => setCompareTarget(event.target.value)}>
|
||||
<option value="current">当前线上版本</option>
|
||||
{compareCandidates.map((item) => (
|
||||
<option key={item.id} value={String(item.id)}>
|
||||
#{item.id} · {item.created_at}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
<span>Diff 摘要</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="success">+{diffStats.additions}</Badge>
|
||||
<Badge variant="secondary">-{diffStats.deletions}</Badge>
|
||||
<Badge variant="outline">metadata 变更 {metadataChanges.length}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-6 text-muted-foreground">
|
||||
基线:{comparisonLabel}
|
||||
{metadataChanges.length ? ` · 变化字段:${metadataChanges.join('、')}` : ' · Frontmatter 无变化'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
|
||||
<div className="font-medium text-foreground">恢复模式</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(['full', 'markdown', 'metadata'] as RestoreMode[]).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
size="sm"
|
||||
disabled={restoring !== null || !selected.item.has_markdown}
|
||||
onClick={() => void runRestore(mode)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{restoring === `${selected.item.id}:${mode}` ? '恢复中...' : mode}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-6 text-muted-foreground">
|
||||
full:整篇恢复;markdown:只恢复正文;metadata:只恢复 frontmatter / SEO / 生命周期等元信息。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<LabelRow title={comparisonLabel} />
|
||||
<Textarea
|
||||
value={comparisonMarkdown}
|
||||
readOnly
|
||||
className="min-h-[280px] font-mono text-xs leading-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<LabelRow title={`版本 #${selected.item.id}`} />
|
||||
<Textarea
|
||||
value={selected.markdown ?? ''}
|
||||
readOnly
|
||||
className="min-h-[280px] font-mono text-xs leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
先从左侧选择一个版本,右侧会显示对应快照内容与 Diff 摘要。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelRow({ title }: { title: string }) {
|
||||
return <div className="text-sm font-medium text-foreground">{title}</div>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
499
admin/src/pages/subscriptions-page.tsx
Normal file
499
admin/src/pages/subscriptions-page.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'discord', label: 'Discord Webhook' },
|
||||
{ value: 'telegram', label: 'Telegram Bot API' },
|
||||
{ value: 'ntfy', label: 'ntfy' },
|
||||
] as const
|
||||
|
||||
const DEFAULT_FILTERS = {
|
||||
event_types: ['post.published', 'digest.weekly', 'digest.monthly'],
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown) {
|
||||
if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0)) {
|
||||
return ''
|
||||
}
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
channelType: 'email',
|
||||
target: '',
|
||||
displayName: '',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
filtersText: prettyJson(DEFAULT_FILTERS),
|
||||
metadataText: '',
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalJson(label: string, raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error(`${label} 不是合法 JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreview(value: unknown) {
|
||||
const text = prettyJson(value)
|
||||
return text || '—'
|
||||
}
|
||||
|
||||
export function SubscriptionsPage() {
|
||||
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
|
||||
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null)
|
||||
const [actioningId, setActioningId] = useState<number | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState(emptyForm())
|
||||
|
||||
const loadData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
if (showToast) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const [nextSubscriptions, nextDeliveries] = await Promise.all([
|
||||
adminApi.listSubscriptions(),
|
||||
adminApi.listSubscriptionDeliveries(),
|
||||
])
|
||||
startTransition(() => {
|
||||
setSubscriptions(nextSubscriptions)
|
||||
setDeliveries(nextDeliveries)
|
||||
})
|
||||
if (showToast) {
|
||||
toast.success('订阅中心已刷新。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return
|
||||
}
|
||||
toast.error(error instanceof ApiError ? error.message : '无法加载订阅中心。')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(false)
|
||||
}, [loadData])
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => subscriptions.filter((item) => item.status === 'active').length,
|
||||
[subscriptions],
|
||||
)
|
||||
|
||||
const queuedOrRetryCount = useMemo(
|
||||
() => deliveries.filter((item) => item.status === 'queued' || item.status === 'retry_pending').length,
|
||||
[deliveries],
|
||||
)
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
}, [])
|
||||
|
||||
const submitForm = useCallback(async () => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const payload = {
|
||||
channelType: form.channelType,
|
||||
target: form.target,
|
||||
displayName: form.displayName || null,
|
||||
status: form.status,
|
||||
notes: form.notes || null,
|
||||
filters: parseOptionalJson('filters', form.filtersText),
|
||||
metadata: parseOptionalJson('metadata', form.metadataText),
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await adminApi.updateSubscription(editingId, payload)
|
||||
toast.success('订阅目标已更新。')
|
||||
} else {
|
||||
await adminApi.createSubscription(payload)
|
||||
toast.success('订阅目标已创建。')
|
||||
}
|
||||
|
||||
resetForm()
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '保存订阅失败。')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [editingId, form, loadData, resetForm])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-40 rounded-3xl" />
|
||||
<Skeleton className="h-[640px] rounded-3xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">订阅与推送</Badge>
|
||||
<div>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">订阅中心 / 异步投递 / Digest</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{refreshing ? '刷新中...' : '刷新'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={digesting !== null}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('weekly')
|
||||
const result = await adminApi.sendSubscriptionDigest('weekly')
|
||||
toast.success(`周报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
|
||||
} finally {
|
||||
setDigesting(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{digesting === 'weekly' ? '入队中...' : '发送周报'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={digesting !== null}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setDigesting('monthly')
|
||||
const result = await adminApi.sendSubscriptionDigest('monthly')
|
||||
toast.success(`月报已入队:queued ${result.queued},skipped ${result.skipped}`)
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
|
||||
} finally {
|
||||
setDigesting(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BellRing className="h-4 w-4" />
|
||||
{digesting === 'monthly' ? '入队中...' : '发送月报'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.98fr_1.02fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? `编辑订阅 #${editingId}` : '新增订阅目标'}</CardTitle>
|
||||
<CardDescription>
|
||||
当前共有 {subscriptions.length} 个订阅目标,其中 {activeCount} 个处于启用状态,当前待处理/重试中的投递 {queuedOrRetryCount} 条。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>频道类型</Label>
|
||||
<Select
|
||||
value={form.channelType}
|
||||
onChange={(event) => setForm((current) => ({ ...current, channelType: event.target.value }))}
|
||||
>
|
||||
{CHANNEL_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标地址</Label>
|
||||
<Input
|
||||
value={form.target}
|
||||
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
|
||||
placeholder={form.channelType === 'email' ? 'name@example.com' : 'https://...'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>显示名称</Label>
|
||||
<Input
|
||||
value={form.displayName}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, displayName: event.target.value }))
|
||||
}
|
||||
placeholder="例如 站长邮箱 / Discord 运维群"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="paused">paused</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="unsubscribed">unsubscribed</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>备注</Label>
|
||||
<Input
|
||||
value={form.notes}
|
||||
onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))}
|
||||
placeholder="用途、机器人说明、负责人等"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>filters(JSON)</Label>
|
||||
<Textarea
|
||||
value={form.filtersText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, filtersText: event.target.value }))}
|
||||
placeholder='{"event_types":["post.published","digest.weekly"]}'
|
||||
className="min-h-32 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>metadata(JSON,可选)</Label>
|
||||
<Textarea
|
||||
value={form.metadataText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, metadataText: event.target.value }))}
|
||||
placeholder='{"owner":"ops","source":"manual"}'
|
||||
className="min-h-28 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
|
||||
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
|
||||
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
|
||||
</Button>
|
||||
{editingId ? (
|
||||
<Button variant="outline" disabled={submitting} onClick={resetForm}>
|
||||
<X className="h-4 w-4" />
|
||||
取消编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>当前订阅目标</CardTitle>
|
||||
<CardDescription>支持单条测试、编辑 filters / metadata,以及删除。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{subscriptions.length} 个</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>目标</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>偏好</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subscriptions.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{item.channel_type}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
|
||||
<div>{item.target}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground/80">
|
||||
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
失败 {item.failure_count ?? 0} 次 · 最近 {item.last_delivery_status ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{normalizePreview(item.filters)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingId(item.id)
|
||||
setForm({
|
||||
channelType: item.channel_type,
|
||||
target: item.target,
|
||||
displayName: item.display_name ?? '',
|
||||
status: item.status,
|
||||
notes: item.notes ?? '',
|
||||
filtersText: prettyJson(item.filters),
|
||||
metadataText: prettyJson(item.metadata),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.testSubscription(item.id)
|
||||
toast.success('测试通知已入队。')
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={actioningId === item.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setActioningId(item.id)
|
||||
await adminApi.deleteSubscription(item.id)
|
||||
toast.success('订阅目标已删除。')
|
||||
if (editingId === item.id) {
|
||||
resetForm()
|
||||
}
|
||||
await loadData(false)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除失败。')
|
||||
} finally {
|
||||
setActioningId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>最近投递记录</CardTitle>
|
||||
<CardDescription>关注 attempts / next retry / response,确认异步投递与重试状态。</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">{deliveries.length} 条</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>事件</TableHead>
|
||||
<TableHead>频道</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>重试</TableHead>
|
||||
<TableHead>响应</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deliveries.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.event_type}</div>
|
||||
<div className="text-xs text-muted-foreground">#{item.subscription_id ?? '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{item.channel_type}</div>
|
||||
<div className="line-clamp-1 text-xs text-muted-foreground">{item.target}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.status === 'sent' ? 'success' : item.status === 'retry_pending' ? 'warning' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<div>attempts: {item.attempts_count}</div>
|
||||
<div>next: {item.next_retry_at ?? '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{item.response_text ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
admin/src/vite-env.d.ts
vendored
1
admin/src/vite-env.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string
|
||||
readonly VITE_ADMIN_BASENAME?: string
|
||||
readonly VITE_FRONTEND_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
|
||||
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
target
|
||||
.git
|
||||
.github
|
||||
.gitea
|
||||
node_modules
|
||||
*.log
|
||||
*.out
|
||||
*.err
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
**/config/local.yaml
|
||||
**/config/*.local.yaml
|
||||
**/config/production.yaml
|
||||
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
|
||||
1124
backend/Cargo.lock
generated
1124
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -36,15 +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", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
|
||||
fastembed = "5.1"
|
||||
async-stream = "0.3"
|
||||
base64 = "0.22"
|
||||
aws-config = "1"
|
||||
aws-sdk-s3 = "1"
|
||||
|
||||
[[bin]]
|
||||
name = "termi_api-cli"
|
||||
|
||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM rust:1.88-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY migration/Cargo.toml migration/Cargo.toml
|
||||
COPY src src
|
||||
COPY migration/src migration/src
|
||||
COPY config config
|
||||
COPY assets assets
|
||||
|
||||
RUN cargo build --release --locked --bin termi_api-cli
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/termi_api-cli /usr/local/bin/termi_api-cli
|
||||
COPY --from=builder /app/config ./config
|
||||
COPY --from=builder /app/assets ./assets
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV RUST_LOG=info
|
||||
EXPOSE 5150
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=5 CMD wget -q -O /dev/null http://127.0.0.1:5150/healthz || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["termi_api-cli", "-e", "production", "start", "--no-banner"]
|
||||
@@ -1,58 +1,37 @@
|
||||
# Welcome to Loco :train:
|
||||
# backend
|
||||
|
||||
[Loco](https://loco.rs) is a web and API framework running on Rust.
|
||||
Loco.rs backend,当前仅保留 API 与后台鉴权相关逻辑,不再提供旧的 Tera HTML 后台页面。
|
||||
|
||||
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
|
||||
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
|
||||
## 本地启动
|
||||
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 长文测试
|
||||
|
||||
@@ -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"
|
||||
|
||||
55
backend/assets/seeds/site_settings.yaml
Normal file
55
backend/assets/seeds/site_settings.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
- id: 1
|
||||
site_name: "InitCool"
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://init.cool"
|
||||
site_title: "InitCool · 中文长文与 AI 搜索实验站"
|
||||
site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。"
|
||||
hero_title: "欢迎来到我的中文内容实验站"
|
||||
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
owner_avatar_url: "https://github.com/limitcool.png"
|
||||
social_github: "https://github.com/limitcool"
|
||||
social_twitter: ""
|
||||
social_email: "mailto:initcoool@gmail.com"
|
||||
location: "中国香港"
|
||||
tech_stack:
|
||||
- "Rust"
|
||||
- "Go"
|
||||
- "Python"
|
||||
- "Svelte"
|
||||
- "Astro"
|
||||
- "Loco.rs"
|
||||
music_playlist:
|
||||
- title: "山中来信"
|
||||
artist: "InitCool Radio"
|
||||
album: "站点默认歌单"
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
|
||||
cover_image_url: "https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80"
|
||||
accent_color: "#2f6b5f"
|
||||
description: "适合文章阅读时循环播放的轻氛围曲。"
|
||||
- title: "风吹松声"
|
||||
artist: "InitCool Radio"
|
||||
album: "站点默认歌单"
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3"
|
||||
cover_image_url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80"
|
||||
accent_color: "#8a5b35"
|
||||
description: "偏木质感的器乐氛围,适合深夜浏览。"
|
||||
- title: "夜航小记"
|
||||
artist: "InitCool Radio"
|
||||
album: "站点默认歌单"
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3"
|
||||
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
|
||||
accent_color: "#375a7f"
|
||||
description: "节奏更明显一点,适合切换阅读状态。"
|
||||
ai_enabled: false
|
||||
paragraph_comments_enabled: true
|
||||
ai_provider: "newapi"
|
||||
ai_api_base: "https://91code.jiangnight.com/v1"
|
||||
ai_api_key: "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571"
|
||||
ai_chat_model: "gpt-5.4"
|
||||
ai_embedding_model: "fastembed / local all-MiniLM-L6-v2"
|
||||
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
|
||||
ai_top_k: 4
|
||||
ai_chunk_size: 1200
|
||||
@@ -1,3 +0,0 @@
|
||||
<html><body>
|
||||
not found :-(
|
||||
</body></html>
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0; url=/admin">
|
||||
<title>Redirecting...</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to <a href="/admin">Admin Dashboard</a>...</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,705 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page_title | default(value="Termi Admin") }} · Termi Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f4f5;
|
||||
--bg-panel: rgba(255, 255, 255, 0.88);
|
||||
--bg-panel-strong: rgba(255, 255, 255, 0.98);
|
||||
--line: rgba(24, 24, 27, 0.09);
|
||||
--line-strong: rgba(24, 24, 27, 0.16);
|
||||
--text: #09090b;
|
||||
--text-soft: #52525b;
|
||||
--text-mute: #71717a;
|
||||
--accent: #18181b;
|
||||
--accent-2: #2563eb;
|
||||
--accent-3: #dc2626;
|
||||
--accent-4: #16a34a;
|
||||
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 12px;
|
||||
--font-sans: "Inter", "Segoe UI", "PingFang SC", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, #fafafa 0%, #f4f4f5 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 290px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.surface,
|
||||
.stat,
|
||||
.table-panel,
|
||||
.hero-card,
|
||||
.form-panel,
|
||||
.login-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 28px 22px;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: #111827;
|
||||
border: 1px solid #111827;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
color: var(--text-soft);
|
||||
border: 1px solid transparent;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
box-shadow: inset 0 0 0 1px rgba(24, 24, 27, 0.06);
|
||||
}
|
||||
|
||||
.nav-kicker {
|
||||
margin-top: auto;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.nav-kicker strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.nav-kicker p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.55;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 26px 28px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(24, 24, 27, 0.05);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 12px 0 8px;
|
||||
font-size: clamp(1.7rem, 2.2vw, 2.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fafafa;
|
||||
box-shadow: 0 10px 24px rgba(24, 24, 27, 0.16);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border-color: rgba(220, 38, 38, 0.14);
|
||||
color: var(--accent-3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: rgba(22, 163, 74, 0.08);
|
||||
border-color: rgba(22, 163, 74, 0.14);
|
||||
color: var(--accent-4);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-color: rgba(245, 158, 11, 0.16);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat,
|
||||
.hero-card,
|
||||
.table-panel,
|
||||
.form-panel {
|
||||
padding: 22px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-panel-strong);
|
||||
}
|
||||
|
||||
.stat-label,
|
||||
.muted,
|
||||
.table-note,
|
||||
.field-hint,
|
||||
.badge-soft {
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tone-blue .stat-value { color: var(--accent-2); }
|
||||
.tone-gold .stat-value { color: var(--accent); }
|
||||
.tone-green .stat-value { color: var(--accent-4); }
|
||||
.tone-pink .stat-value { color: var(--accent-3); }
|
||||
.tone-violet .stat-value { color: #7a5ef4; }
|
||||
|
||||
.table-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.table-head h2,
|
||||
.hero-card h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 880px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid rgba(93, 76, 56, 0.1);
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(250, 250, 250, 0.98);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title strong {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.item-meta,
|
||||
.mono {
|
||||
color: var(--text-soft);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.84rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
color: var(--accent-4);
|
||||
background: rgba(93, 122, 45, 0.1);
|
||||
border-color: rgba(93, 122, 45, 0.14);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
color: var(--accent);
|
||||
background: rgba(202, 94, 45, 0.1);
|
||||
border-color: rgba(202, 94, 45, 0.14);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
color: var(--accent-3);
|
||||
background: rgba(156, 61, 84, 0.1);
|
||||
border-color: rgba(156, 61, 84, 0.14);
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: rgba(241, 245, 249, 0.95);
|
||||
color: var(--text-soft);
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-link {
|
||||
color: var(--accent-2);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inline-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 18px;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
color: var(--text);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inline-form.compact {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.compact-grid textarea,
|
||||
.compact-grid input,
|
||||
.compact-grid select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.compact-grid textarea {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.compact-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.notice.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notice-success {
|
||||
color: var(--accent-4);
|
||||
background: rgba(22, 163, 74, 0.08);
|
||||
border-color: rgba(22, 163, 74, 0.14);
|
||||
}
|
||||
|
||||
.notice-error {
|
||||
color: var(--accent-3);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border-color: rgba(220, 38, 38, 0.14);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(520px, 100%);
|
||||
padding: 34px;
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.login-panel p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
display: none;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border: 1px solid rgba(220, 38, 38, 0.14);
|
||||
color: var(--accent-3);
|
||||
}
|
||||
|
||||
.login-error.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell,
|
||||
.surface {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 760px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div>
|
||||
<div class="brand-mark">/></div>
|
||||
<h1 class="brand-title">Termi Admin</h1>
|
||||
<p class="brand-copy">后台数据直接联动前台页面。你可以在这里审核评论和友链、检查分类标签,并跳到对应前台页面确认效果。</p>
|
||||
</div>
|
||||
|
||||
<nav class="nav-group">
|
||||
<a href="/admin" class="nav-item {% if active_nav == 'dashboard' %}active{% endif %}">概览面板</a>
|
||||
<a href="/admin/posts" class="nav-item {% if active_nav == 'posts' %}active{% endif %}">文章管理</a>
|
||||
<a href="/admin/comments" class="nav-item {% if active_nav == 'comments' %}active{% endif %}">评论审核</a>
|
||||
<a href="/admin/categories" class="nav-item {% if active_nav == 'categories' %}active{% endif %}">分类管理</a>
|
||||
<a href="/admin/tags" class="nav-item {% if active_nav == 'tags' %}active{% endif %}">标签管理</a>
|
||||
<a href="/admin/reviews" class="nav-item {% if active_nav == 'reviews' %}active{% endif %}">评价管理</a>
|
||||
<a href="/admin/friend_links" class="nav-item {% if active_nav == 'friend_links' %}active{% endif %}">友链申请</a>
|
||||
<a href="/admin/site-settings" class="nav-item {% if active_nav == 'site_settings' %}active{% endif %}">站点设置</a>
|
||||
</nav>
|
||||
|
||||
<div class="nav-kicker">
|
||||
<strong>前台联调入口</strong>
|
||||
<p>所有管理页都带了前台直达链接,处理完数据后可以立刻跳转验证。</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="content-shell">
|
||||
<header class="surface topbar">
|
||||
<div>
|
||||
<span class="eyebrow">Unified Admin</span>
|
||||
<h1 class="page-title">{{ page_title | default(value="Termi Admin") }}</h1>
|
||||
<p class="page-description">{{ page_description | default(value="统一处理后台数据与前台联调。") }}</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
{% for item in header_actions | default(value=[]) %}
|
||||
<a
|
||||
href="{{ item.href }}"
|
||||
class="btn btn-{{ item.variant }}"
|
||||
{% if item.external %}target="_blank" rel="noreferrer noopener"{% endif %}
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a href="/admin/logout" class="btn btn-danger">退出后台</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content-grid">
|
||||
{% block main_content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
async function adminPatch(url, payload, successMessage) {
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "request failed");
|
||||
}
|
||||
|
||||
if (successMessage) {
|
||||
alert(successMessage);
|
||||
}
|
||||
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function adminDelete(url, successMessage) {
|
||||
const confirmed = confirm("确认删除这条记录吗?此操作无法撤销。");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "request failed");
|
||||
}
|
||||
|
||||
if (successMessage) {
|
||||
alert(successMessage);
|
||||
}
|
||||
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
{% block page_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,85 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>新增分类</h2>
|
||||
<div class="table-note">这里维护分类字典。文章 Markdown 导入时会优先复用这里的分类,不存在才自动创建。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/categories" class="inline-form">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="name" placeholder="分类名,例如 Technology" value="{{ create_form.name }}" required>
|
||||
<input type="text" name="slug" placeholder="slug,可留空自动生成" value="{{ create_form.slug }}">
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-primary">创建分类</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>分类列表</h2>
|
||||
<div class="table-note">分类名称会作为文章展示名称使用,文章数来自当前已同步的真实内容。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>分类</th>
|
||||
<th>文章数</th>
|
||||
<th>最近文章</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/categories/{{ row.id }}/update" class="inline-form compact">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="name" value="{{ row.name }}" required>
|
||||
<input type="text" name="slug" value="{{ row.slug }}" required>
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-success">保存</button>
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="chip">{{ row.count }} 篇</span></td>
|
||||
<td>
|
||||
{% if row.latest_frontend_url %}
|
||||
<a href="{{ row.latest_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.latest_title }}</a>
|
||||
{% else %}
|
||||
<span class="badge-soft">{{ row.latest_title }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台分类页</a>
|
||||
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
|
||||
<form method="post" action="/admin/categories/{{ row.id }}/delete">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无分类数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,147 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>评论筛选</h2>
|
||||
<div class="table-note">按 scope、审核状态、文章 slug 或关键词快速定位评论,尤其适合处理段落评论和垃圾留言。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/admin/comments" class="inline-form compact">
|
||||
<div class="compact-grid">
|
||||
<div class="field">
|
||||
<label for="scope">评论类型</label>
|
||||
<select id="scope" name="scope">
|
||||
<option value="" {% if filters.scope == "" %}selected{% endif %}>全部</option>
|
||||
<option value="article" {% if filters.scope == "article" %}selected{% endif %}>全文评论</option>
|
||||
<option value="paragraph" {% if filters.scope == "paragraph" %}selected{% endif %}>段落评论</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="approved">审核状态</label>
|
||||
<select id="approved" name="approved">
|
||||
<option value="" {% if filters.approved == "" %}selected{% endif %}>全部</option>
|
||||
<option value="true" {% if filters.approved == "true" %}selected{% endif %}>已审核</option>
|
||||
<option value="false" {% if filters.approved == "false" %}selected{% endif %}>待审核</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="post_slug">文章</label>
|
||||
<select id="post_slug" name="post_slug">
|
||||
<option value="" {% if filters.post_slug == "" %}selected{% endif %}>全部文章</option>
|
||||
{% for slug in post_options %}
|
||||
<option value="{{ slug }}" {% if filters.post_slug == slug %}selected{% endif %}>{{ slug }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="q">关键词</label>
|
||||
<input
|
||||
id="q"
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ filters.q }}"
|
||||
placeholder="作者 / 内容 / 段落 key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-primary">应用筛选</button>
|
||||
<a href="/admin/comments" class="btn btn-ghost">清空</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="inline-links" style="margin-top: 14px;">
|
||||
{% for stat in stats %}
|
||||
<span class="chip">{{ stat.label }} · {{ stat.value }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>评论队列</h2>
|
||||
<div class="table-note">处理前台真实评论,并能一键跳到对应文章或段落核对上下文。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>作者 / 文章</th>
|
||||
<th>内容与上下文</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<div class="item-title">
|
||||
<strong>{{ row.author }}</strong>
|
||||
<span class="item-meta">{{ row.post_slug }}</span>
|
||||
{% if row.scope == "paragraph" %}
|
||||
<span class="badge badge-warning">{{ row.scope_label }}</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ row.scope_label }}</span>
|
||||
{% endif %}
|
||||
{% if row.frontend_url %}
|
||||
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">
|
||||
{% if row.scope == "paragraph" %}跳到前台段落{% else %}跳到前台文章{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="item-title">
|
||||
<strong>{{ row.content }}</strong>
|
||||
{% if row.reply_target != "-" %}
|
||||
<span class="item-meta">回复目标:{{ row.reply_target }}</span>
|
||||
{% endif %}
|
||||
{% if row.scope == "paragraph" and row.paragraph_excerpt != "-" %}
|
||||
<span class="item-meta">段落上下文:{{ row.paragraph_excerpt }}</span>
|
||||
{% endif %}
|
||||
{% if row.scope == "paragraph" and row.paragraph_key != "-" %}
|
||||
<span class="item-meta">段落 key:{{ row.paragraph_key }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if row.approved %}
|
||||
<span class="badge badge-success">已审核</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">待审核</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ row.created_at }}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
|
||||
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
|
||||
<button class="btn btn-danger" onclick='adminDelete("{{ row.api_url }}", "评论已删除")'>删除</button>
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">当前筛选条件下暂无评论数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>友链审核</h2>
|
||||
<div class="table-note">前台提交后会进入这里,你可以审核状态,再跳去前台友链页确认展示。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>站点</th>
|
||||
<th>分类</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<div class="item-title">
|
||||
<strong>{{ row.site_name }}</strong>
|
||||
<a href="{{ row.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.site_url }}</a>
|
||||
<span class="item-meta">{{ row.description }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ row.category_name }}</td>
|
||||
<td>
|
||||
{% if row.status == "已通过" %}
|
||||
<span class="badge badge-success">{{ row.status }}</span>
|
||||
{% elif row.status == "已拒绝" %}
|
||||
<span class="badge badge-danger">{{ row.status }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">{{ row.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ row.created_at }}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"status": "approved"}, "友链状态已更新")'>通过</button>
|
||||
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"status": "pending"}, "友链状态已更新")'>待审</button>
|
||||
<button class="btn btn-danger" onclick='adminPatch("{{ row.api_url }}", {"status": "rejected"}, "友链状态已更新")'>拒绝</button>
|
||||
<a href="{{ row.frontend_page_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台友链页</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无友链申请数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="stats-grid">
|
||||
{% for stat in stats %}
|
||||
<article class="stat tone-{{ stat.tone }}">
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="muted">{{ stat.note }}</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<section class="hero-card">
|
||||
<h2>{{ site_profile.site_name }}</h2>
|
||||
<p class="page-description" style="margin-bottom: 10px;">{{ site_profile.site_description }}</p>
|
||||
<a href="{{ site_profile.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ site_profile.site_url }}</a>
|
||||
</section>
|
||||
|
||||
<section class="card-grid">
|
||||
{% for card in nav_cards %}
|
||||
<a href="{{ card.href }}" class="hero-card">
|
||||
<h2>{{ card.title }}</h2>
|
||||
<p class="page-description" style="margin-bottom: 10px;">{{ card.description }}</p>
|
||||
<span class="chip">{{ card.meta }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,35 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block body %}
|
||||
<div class="login-shell">
|
||||
<section class="login-panel">
|
||||
<span class="eyebrow">Termi Admin</span>
|
||||
<div class="brand-mark" style="margin-top: 18px;">/></div>
|
||||
<h1>后台管理入口</h1>
|
||||
<p>评论审核、友链申请、分类标签检查和站点设置都在这里统一处理。当前后台界面已经走 Tera 模板,不再在 Rust 里硬拼整页 HTML。</p>
|
||||
|
||||
<div class="login-error {% if show_error %}show{% endif %}">
|
||||
用户名或密码错误,请重试。
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/admin/login" class="form-grid" style="margin-top: 22px;">
|
||||
<div class="field field-wide">
|
||||
<label>用户名</label>
|
||||
<input name="username" placeholder="admin" required>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="admin123" required>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">进入后台</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="hero-card" style="margin-top: 18px;">
|
||||
<h2>默认测试账号</h2>
|
||||
<p class="mono">admin / admin123</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>{{ editor.title }}</h2>
|
||||
<div class="table-note">当前源文件:<span class="mono">{{ editor.file_path }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="markdown-editor-form" class="form-grid">
|
||||
<div class="field field-wide">
|
||||
<label>Slug</label>
|
||||
<input value="{{ editor.slug }}" readonly>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>Markdown 文件内容</label>
|
||||
<textarea id="markdown-content" name="markdown" style="min-height: 65vh; font-family: var(--font-mono); line-height: 1.65;">{{ editor.markdown }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">保存 Markdown</button>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">这里保存的是服务器上的原始 Markdown 文件。你也可以直接在服务器用编辑器打开这个路径修改。</div>
|
||||
<div id="notice" class="notice"></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<script>
|
||||
const markdownForm = document.getElementById("markdown-editor-form");
|
||||
const markdownField = document.getElementById("markdown-content");
|
||||
const markdownNotice = document.getElementById("notice");
|
||||
const markdownSlug = "{{ editor.slug }}";
|
||||
|
||||
function showMarkdownNotice(message, kind) {
|
||||
markdownNotice.textContent = message;
|
||||
markdownNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||
}
|
||||
|
||||
markdownForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/slug/${markdownSlug}/markdown`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
markdown: markdownField.value
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "save failed");
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
markdownField.value = payload.markdown;
|
||||
showMarkdownNotice("Markdown 文件已保存并同步到数据库。", "success");
|
||||
} catch (error) {
|
||||
showMarkdownNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,199 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>新建 Markdown 文章</h2>
|
||||
<div class="table-note">直接生成 `content/posts/*.md` 文件,后端会自动解析 frontmatter、同步分类和标签。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/posts" class="form-grid">
|
||||
<div class="field">
|
||||
<label>标题</label>
|
||||
<input type="text" name="title" value="{{ create_form.title }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Slug</label>
|
||||
<input type="text" name="slug" value="{{ create_form.slug }}" placeholder="可留空自动生成">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>分类</label>
|
||||
<input type="text" name="category" value="{{ create_form.category }}" placeholder="例如 tech">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>标签</label>
|
||||
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="逗号分隔">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>文章类型</label>
|
||||
<input type="text" name="post_type" value="{{ create_form.post_type }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>封面图</label>
|
||||
<input type="text" name="image" value="{{ create_form.image }}" placeholder="可选">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>摘要</label>
|
||||
<textarea name="description">{{ create_form.description }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>正文 Markdown</label>
|
||||
<textarea name="content" style="min-height: 22rem; font-family: var(--font-mono); line-height: 1.65;">{{ create_form.content }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<div class="actions">
|
||||
<label class="chip"><input type="checkbox" name="published" checked style="margin-right: 8px;">发布</label>
|
||||
<label class="chip"><input type="checkbox" name="pinned" style="margin-right: 8px;">置顶</label>
|
||||
<button type="submit" class="btn btn-primary">创建文章</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>导入 Markdown 文件</h2>
|
||||
<div class="table-note">支持选择单个 `.md/.markdown` 文件,也支持直接选择一个本地 Markdown 文件夹批量导入。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="markdown-import-form" class="form-grid">
|
||||
<div class="field">
|
||||
<label>选择文件</label>
|
||||
<input id="markdown-files" type="file" accept=".md,.markdown" multiple>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>选择文件夹</label>
|
||||
<input id="markdown-folder" type="file" accept=".md,.markdown" webkitdirectory directory multiple>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<div class="actions">
|
||||
<button id="import-submit" type="submit" class="btn btn-success">导入 Markdown</button>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">导入时会从 frontmatter 和正文里提取标题、slug、摘要、分类、标签与内容,并写入服务器 `content/posts`。</div>
|
||||
<div id="import-notice" class="notice"></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>内容列表</h2>
|
||||
<div class="table-note">直接跳到前台文章、分类筛选和 API 明细。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>文章</th>
|
||||
<th>分类</th>
|
||||
<th>标签</th>
|
||||
<th>时间</th>
|
||||
<th>跳转</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<div class="item-title">
|
||||
<strong>{{ row.title }}</strong>
|
||||
<span class="item-meta">{{ row.slug }}</span>
|
||||
<span class="item-meta">{{ row.file_path }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="item-title">
|
||||
<strong>{{ row.category_name }}</strong>
|
||||
<a href="{{ row.category_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">查看该分类文章</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="inline-links">
|
||||
{% if row.tags | length > 0 %}
|
||||
{% for tag in row.tags %}
|
||||
<span class="chip">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="badge-soft">暂无标签</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">{{ row.created_at }}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a href="{{ row.edit_url }}" class="btn btn-success">编辑 Markdown</a>
|
||||
<a href="{{ row.frontend_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台详情</a>
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">当前没有可管理的文章数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<script>
|
||||
const importForm = document.getElementById("markdown-import-form");
|
||||
const importFiles = document.getElementById("markdown-files");
|
||||
const importFolder = document.getElementById("markdown-folder");
|
||||
const importNotice = document.getElementById("import-notice");
|
||||
|
||||
function showImportNotice(message, kind) {
|
||||
importNotice.textContent = message;
|
||||
importNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||
}
|
||||
|
||||
importForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const selectedFiles = [
|
||||
...(importFiles?.files ? Array.from(importFiles.files) : []),
|
||||
...(importFolder?.files ? Array.from(importFolder.files) : []),
|
||||
].filter((file) => file.name.endsWith(".md") || file.name.endsWith(".markdown"));
|
||||
|
||||
if (!selectedFiles.length) {
|
||||
showImportNotice("请先选择要导入的 Markdown 文件或文件夹。", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = new FormData();
|
||||
selectedFiles.forEach((file) => {
|
||||
const uploadName = file.webkitRelativePath || file.name;
|
||||
payload.append("files", file, uploadName);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch("/admin/posts/import", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "import failed");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
showImportNotice(`已导入 ${result.count} 个 Markdown 文件,正在刷新列表。`, "success");
|
||||
setTimeout(() => window.location.reload(), 900);
|
||||
} catch (error) {
|
||||
showImportNotice("导入失败:" + (error?.message || "unknown error"), "error");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,108 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>新增评价</h2>
|
||||
<div class="table-note">这里创建的评价会立刻出现在前台 `/reviews` 页面。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/reviews" class="inline-form">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="title" placeholder="标题" value="{{ create_form.title }}" required>
|
||||
<select name="review_type">
|
||||
<option value="game" {% if create_form.review_type == "game" %}selected{% endif %}>游戏</option>
|
||||
<option value="anime" {% if create_form.review_type == "anime" %}selected{% endif %}>动画</option>
|
||||
<option value="music" {% if create_form.review_type == "music" %}selected{% endif %}>音乐</option>
|
||||
<option value="book" {% if create_form.review_type == "book" %}selected{% endif %}>书籍</option>
|
||||
<option value="movie" {% if create_form.review_type == "movie" %}selected{% endif %}>影视</option>
|
||||
</select>
|
||||
<input type="number" name="rating" min="0" max="5" value="{{ create_form.rating }}" required>
|
||||
<input type="date" name="review_date" value="{{ create_form.review_date }}">
|
||||
<select name="status">
|
||||
<option value="completed" {% if create_form.status == "completed" %}selected{% endif %}>已完成</option>
|
||||
<option value="in-progress" {% if create_form.status == "in-progress" %}selected{% endif %}>进行中</option>
|
||||
<option value="dropped" {% if create_form.status == "dropped" %}selected{% endif %}>已弃坑</option>
|
||||
</select>
|
||||
<input type="text" name="cover" value="{{ create_form.cover }}" placeholder="封面图标或 emoji">
|
||||
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="标签,逗号分隔">
|
||||
<textarea name="description" placeholder="评价描述">{{ create_form.description }}</textarea>
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-primary">创建评价</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>评价列表</h2>
|
||||
<div class="table-note">这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>评价内容</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/reviews/{{ row.id }}/update" class="inline-form compact">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="title" value="{{ row.title }}" required>
|
||||
<select name="review_type">
|
||||
<option value="game" {% if row.review_type == "game" %}selected{% endif %}>游戏</option>
|
||||
<option value="anime" {% if row.review_type == "anime" %}selected{% endif %}>动画</option>
|
||||
<option value="music" {% if row.review_type == "music" %}selected{% endif %}>音乐</option>
|
||||
<option value="book" {% if row.review_type == "book" %}selected{% endif %}>书籍</option>
|
||||
<option value="movie" {% if row.review_type == "movie" %}selected{% endif %}>影视</option>
|
||||
</select>
|
||||
<input type="number" name="rating" min="0" max="5" value="{{ row.rating }}" required>
|
||||
<input type="date" name="review_date" value="{{ row.review_date }}">
|
||||
<select name="status">
|
||||
<option value="completed" {% if row.status == "completed" %}selected{% endif %}>已完成</option>
|
||||
<option value="in-progress" {% if row.status == "in-progress" %}selected{% endif %}>进行中</option>
|
||||
<option value="dropped" {% if row.status == "dropped" %}selected{% endif %}>已弃坑</option>
|
||||
</select>
|
||||
<input type="text" name="cover" value="{{ row.cover }}" placeholder="封面图标或 emoji">
|
||||
<input type="text" name="tags" value="{{ row.tags_input }}" placeholder="标签,逗号分隔">
|
||||
<textarea name="description" placeholder="评价描述">{{ row.description }}</textarea>
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-success">保存</button>
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="chip">{{ row.status }}</span></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a href="http://localhost:4321/reviews" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台查看</a>
|
||||
<form method="post" action="/admin/reviews/{{ row.id }}/delete">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无评价数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,224 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>站点资料</h2>
|
||||
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。AI 问答也在这里统一开启和配置。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="site-settings-form" class="form-grid">
|
||||
<div class="field">
|
||||
<label>站点名称</label>
|
||||
<input name="site_name" value="{{ form.site_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>短名称</label>
|
||||
<input name="site_short_name" value="{{ form.site_short_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>站点链接</label>
|
||||
<input name="site_url" value="{{ form.site_url }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>站点标题</label>
|
||||
<input name="site_title" value="{{ form.site_title }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>站点简介</label>
|
||||
<textarea name="site_description">{{ form.site_description }}</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>首页主标题</label>
|
||||
<input name="hero_title" value="{{ form.hero_title }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>首页副标题</label>
|
||||
<input name="hero_subtitle" value="{{ form.hero_subtitle }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>个人名称</label>
|
||||
<input name="owner_name" value="{{ form.owner_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>个人头衔</label>
|
||||
<input name="owner_title" value="{{ form.owner_title }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>头像 URL</label>
|
||||
<input name="owner_avatar_url" value="{{ form.owner_avatar_url }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>所在地</label>
|
||||
<input name="location" value="{{ form.location }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>GitHub</label>
|
||||
<input name="social_github" value="{{ form.social_github }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Twitter / X</label>
|
||||
<input name="social_twitter" value="{{ form.social_twitter }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>Email / mailto</label>
|
||||
<input name="social_email" value="{{ form.social_email }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>个人简介</label>
|
||||
<textarea name="owner_bio">{{ form.owner_bio }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>技术栈(每行一个)</label>
|
||||
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field field-wide" style="border-top: 1px solid rgba(148, 163, 184, 0.18); padding-top: 18px; margin-top: 10px;">
|
||||
<label style="display:flex; align-items:center; gap:10px;">
|
||||
<input type="checkbox" name="ai_enabled" {% if form.ai_enabled %}checked{% endif %}>
|
||||
<span>启用前台 AI 问答</span>
|
||||
</label>
|
||||
<div class="field-hint">关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天 Provider</label>
|
||||
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天 API Base</label>
|
||||
<input name="ai_api_base" value="{{ form.ai_api_base }}" placeholder="http://localhost:8317/v1">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>聊天 API Key</label>
|
||||
<input name="ai_api_key" value="{{ form.ai_api_key }}" placeholder="your-api-key-1">
|
||||
<div class="field-hint">这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入本地 NewAPI 网关,未配置时前台仍可做本地检索,但不会生成完整聊天回答。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天模型</label>
|
||||
<input name="ai_chat_model" value="{{ form.ai_chat_model }}" placeholder="gpt-5.4">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>本地 Embedding</label>
|
||||
<input value="{{ form.ai_local_embedding }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Top K</label>
|
||||
<input type="number" min="1" max="12" name="ai_top_k" value="{{ form.ai_top_k }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Chunk Size</label>
|
||||
<input type="number" min="400" max="4000" step="50" name="ai_chunk_size" value="{{ form.ai_chunk_size }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>系统提示词</label>
|
||||
<textarea name="ai_system_prompt">{{ form.ai_system_prompt }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<div class="table-note">AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。</div>
|
||||
<div id="notice" class="notice"></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_scripts %}
|
||||
<script>
|
||||
const form = document.getElementById("site-settings-form");
|
||||
const notice = document.getElementById("notice");
|
||||
const reindexBtn = document.getElementById("reindex-btn");
|
||||
|
||||
function showNotice(message, kind) {
|
||||
notice.textContent = message;
|
||||
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||
}
|
||||
|
||||
function numericOrNull(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const data = new FormData(form);
|
||||
const payload = {
|
||||
siteName: data.get("site_name"),
|
||||
siteShortName: data.get("site_short_name"),
|
||||
siteUrl: data.get("site_url"),
|
||||
siteTitle: data.get("site_title"),
|
||||
siteDescription: data.get("site_description"),
|
||||
heroTitle: data.get("hero_title"),
|
||||
heroSubtitle: data.get("hero_subtitle"),
|
||||
ownerName: data.get("owner_name"),
|
||||
ownerTitle: data.get("owner_title"),
|
||||
ownerAvatarUrl: data.get("owner_avatar_url"),
|
||||
location: data.get("location"),
|
||||
socialGithub: data.get("social_github"),
|
||||
socialTwitter: data.get("social_twitter"),
|
||||
socialEmail: data.get("social_email"),
|
||||
ownerBio: data.get("owner_bio"),
|
||||
techStack: String(data.get("tech_stack") || "")
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
aiEnabled: data.get("ai_enabled") === "on",
|
||||
aiProvider: data.get("ai_provider"),
|
||||
aiApiBase: data.get("ai_api_base"),
|
||||
aiApiKey: data.get("ai_api_key"),
|
||||
aiChatModel: data.get("ai_chat_model"),
|
||||
aiTopK: numericOrNull(data.get("ai_top_k")),
|
||||
aiChunkSize: numericOrNull(data.get("ai_chunk_size")),
|
||||
aiSystemPrompt: data.get("ai_system_prompt")
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/site_settings", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "save failed");
|
||||
}
|
||||
|
||||
showNotice("站点信息与 AI 配置已保存。", "success");
|
||||
} catch (error) {
|
||||
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||
}
|
||||
});
|
||||
|
||||
reindexBtn?.addEventListener("click", async () => {
|
||||
reindexBtn.disabled = true;
|
||||
reindexBtn.textContent = "正在重建...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/reindex", {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || "reindex failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showNotice(`AI 索引已重建,当前共有 ${data.indexed_chunks} 个片段。`, "success");
|
||||
window.setTimeout(() => window.location.reload(), 900);
|
||||
} catch (error) {
|
||||
showNotice("重建失败:" + (error?.message || "unknown error"), "error");
|
||||
} finally {
|
||||
reindexBtn.disabled = false;
|
||||
reindexBtn.textContent = "重建 AI 索引";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,77 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>新增标签</h2>
|
||||
<div class="table-note">这里维护标签字典。文章 Markdown 导入时会优先复用这里的标签,不存在才自动创建。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/tags" class="inline-form">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="name" placeholder="标签名,例如 Rust" value="{{ create_form.name }}" required>
|
||||
<input type="text" name="slug" placeholder="slug,可留空自动生成" value="{{ create_form.slug }}">
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-primary">创建标签</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>标签映射</h2>
|
||||
<div class="table-note">标签名称会作为文章展示名称使用,使用次数来自当前已同步的真实文章内容。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标签</th>
|
||||
<th>使用次数</th>
|
||||
<th>跳转</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/tags/{{ row.id }}/update" class="inline-form compact">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="name" value="{{ row.name }}" required>
|
||||
<input type="text" name="slug" value="{{ row.slug }}" required>
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-success">保存</button>
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="chip">{{ row.usage_count }} 篇文章</span></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台标签页</a>
|
||||
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
|
||||
<form method="post" action="/admin/tags/{{ row.id }}/delete">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无标签数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
<html><body>
|
||||
<img src="/static/image.png" width="200"/>
|
||||
<br/>
|
||||
find this tera template at <code>assets/views/home/hello.html</code>:
|
||||
<br/>
|
||||
<br/>
|
||||
{{ t(key="hello-world", lang="en-US") }},
|
||||
<br/>
|
||||
{{ t(key="hello-world", lang="de-DE") }}
|
||||
|
||||
</body></html>
|
||||
|
||||
@@ -31,8 +31,6 @@ server:
|
||||
folder:
|
||||
uri: "/static"
|
||||
path: "assets/static"
|
||||
# fallback to index.html which redirects to /admin
|
||||
fallback: "assets/static/index.html"
|
||||
|
||||
# Worker Configuration
|
||||
workers:
|
||||
|
||||
59
backend/config/production.yaml
Normal file
59
backend/config/production.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
logger:
|
||||
enable: true
|
||||
pretty_backtrace: false
|
||||
level: info
|
||||
format: json
|
||||
|
||||
server:
|
||||
port: {{ get_env(name="PORT", default="5150") }}
|
||||
binding: 0.0.0.0
|
||||
host: {{ get_env(name="APP_BASE_URL", default="http://localhost:5150") }}
|
||||
middlewares:
|
||||
static:
|
||||
enable: true
|
||||
must_exist: true
|
||||
precompressed: false
|
||||
folder:
|
||||
uri: "/static"
|
||||
path: "assets/static"
|
||||
|
||||
workers:
|
||||
mode: BackgroundQueue
|
||||
|
||||
queue:
|
||||
kind: Redis
|
||||
uri: {{ get_env(name="REDIS_URL", default="redis://redis:6379") }}
|
||||
dangerously_flush: false
|
||||
|
||||
mailer:
|
||||
smtp:
|
||||
enable: {{ get_env(name="SMTP_ENABLE", default="false") }}
|
||||
host: '{{ get_env(name="SMTP_HOST", default="localhost") }}'
|
||||
port: {{ get_env(name="SMTP_PORT", default="1025") }}
|
||||
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
|
||||
{% set smtp_user = get_env(name="SMTP_USER", default="") %}
|
||||
{% if smtp_user != "" %}
|
||||
auth:
|
||||
user: '{{ smtp_user }}'
|
||||
password: '{{ get_env(name="SMTP_PASSWORD", default="") }}'
|
||||
{% endif %}
|
||||
{% set smtp_hello_name = get_env(name="SMTP_HELLO_NAME", default="") %}
|
||||
{% if smtp_hello_name != "" %}
|
||||
hello_name: '{{ smtp_hello_name }}'
|
||||
{% endif %}
|
||||
|
||||
database:
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://termi:termi@db:5432/termi_api") }}
|
||||
enable_logging: false
|
||||
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="10") }}
|
||||
auto_migrate: true
|
||||
dangerously_truncate: false
|
||||
dangerously_recreate: false
|
||||
|
||||
auth:
|
||||
jwt:
|
||||
secret: {{ get_env(name="JWT_SECRET", default="please-change-me") }}
|
||||
expiration: {{ get_env(name="JWT_EXPIRATION_SECONDS", default="604800") }}
|
||||
@@ -29,7 +29,6 @@ server:
|
||||
folder:
|
||||
uri: "/static"
|
||||
path: "assets/static"
|
||||
fallback: "assets/static/404.html"
|
||||
|
||||
# Worker Configuration
|
||||
workers:
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
title: Building a Blog with Astro
|
||||
slug: building-blog-with-astro
|
||||
description: Learn why Astro is the perfect choice for building fast, content-focused blogs.
|
||||
category: tech
|
||||
post_type: article
|
||||
pinned: false
|
||||
published: true
|
||||
tags:
|
||||
- astro
|
||||
- web-dev
|
||||
- static-site
|
||||
---
|
||||
|
||||
# Building a Blog with Astro
|
||||
|
||||
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||
|
||||
## Why Astro?
|
||||
|
||||
- Zero JavaScript by default
|
||||
- Island Architecture
|
||||
- Framework Agnostic
|
||||
- Great DX
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm create astro@latest
|
||||
cd my-astro-project
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Astro is perfect for content-focused websites like blogs.
|
||||
242
backend/content/posts/canokeys.md
Normal file
242
backend/content/posts/canokeys.md
Normal file
@@ -0,0 +1,242 @@
|
||||
---
|
||||
title: "Canokey入门指南:2FA、OpenPGP、PIV"
|
||||
description: 本文是一份Canokey入门指南,将介绍如何使用Canokey进行2FA、OpenPGP和PIV等操作。其中,2FA部分将介绍如何使用Yubikey Authenticator进行管理,OpenPGP部分将介绍如何生成GPG密钥并使用Canokey进行身份验证和加密解密,PIV部分将介绍如何在Canokey中生成PIV证书并使用其进行身份验证。
|
||||
date: 2022-08-19T16:42:40+08:00
|
||||
draft: false
|
||||
slug: canokeys
|
||||
image:
|
||||
categories:
|
||||
- Linux
|
||||
tags:
|
||||
- Linux
|
||||
---
|
||||
|
||||
|
||||
|
||||
# 2FA
|
||||
|
||||
`Canokey`使用`Yubikey Authenticator`来进行管理`2FA`。
|
||||
|
||||
下载`Yubikey Authenticator`,以下为`Yubikey Authenticator`官方下载网址
|
||||
|
||||
```http
|
||||
https://www.yubico.com/products/yubico-authenticator/#h-download-yubico-authenticator
|
||||
```
|
||||
|
||||
运行`Yubikey Authenticator`
|
||||
|
||||
进入`custom reader`,在`Custom reader fiter`处填入 `CanoKey`
|
||||
|
||||

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

|
||||
|
||||
```yaml
|
||||
Issuer: 备注 可选
|
||||
Account name : 用户名 必填项
|
||||
Secret Key : Hotp或Totp的key 必填项
|
||||
```
|
||||
|
||||
|
||||
# OpenPGP
|
||||
|
||||
## 安装GPG
|
||||
|
||||
Windows 用户可下载 [Gpg4Win](https://gpg4win.org/download.html),Linux/macOS 用户使用对应包管理软件安装即可.
|
||||
|
||||
## 生成主密钥
|
||||
|
||||
```shell
|
||||
gpg --expert --full-gen-key #生成GPG KEY
|
||||
```
|
||||
|
||||
推荐使用`ECC`算法
|
||||
|
||||

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

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

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

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

|
||||
|
||||
```
|
||||
Windnows 下会弹出窗口输入密码,注意一定要保管好!!!
|
||||
```
|
||||
|
||||
```shell
|
||||
|
||||
```
|
||||
|
||||
```shell
|
||||
# 会自动生成吊销证书,注意保存到安全的地方
|
||||
gpg: AllowSetForegroundWindow(22428) failed: <20>ܾ<EFBFBD><DCBE><EFBFBD><EFBFBD>ʡ<EFBFBD>
|
||||
gpg: revocation certificate stored as 'C:\\Users\\Andorid\\AppData\\Roaming\\gnupg\\openpgp-revocs.d\\<此处为私钥>.rev'
|
||||
# 以上的REV文件即为吊销证书
|
||||
public and secret key created and signed.
|
||||
```
|
||||
|
||||
```shell
|
||||
pub ed25519 2022-01-02 [SC]
|
||||
<此处为Pub>
|
||||
uid <此处为Name> <此处为email>
|
||||
```
|
||||
|
||||
生成子密钥
|
||||
|
||||
```shell
|
||||
gpg --fingerprint --keyid-format long -K
|
||||
```
|
||||
|
||||
下面生成不同功能的子密钥,其中 `<fingerprint>` 为上面输出的密钥指纹,本示例中即为 `私钥`。最后的 `2y` 为密钥过期时间,可自行设置,如不填写默认永不过期。
|
||||
|
||||
```shell
|
||||
gpg --quick-add-key <fingerprint> cv25519 encr 2y
|
||||
gpg --quick-add-key <fingerprint> ed25519 auth 2y
|
||||
gpg --quick-add-key <fingerprint> ed25519 sign 2y
|
||||
```
|
||||
|
||||
再次查看目前的私钥,可以看到已经包含了这三个子密钥。
|
||||
|
||||
```shell
|
||||
gpg --fingerprint --keyid-format long -K
|
||||
```
|
||||
|
||||
上面生成了三种功能的子密钥(ssb),分别为加密(E)、认证(A)、签名(S),对应 `OpenPGP Applet` 中的三个插槽。由于 `ECC` 实现的原因,加密密钥的算法区别于其他密钥的算法。
|
||||
|
||||
加密密钥用于加密文件和信息。签名密钥主要用于给自己的信息签名,保证这真的是来自**我**的信息。认证密钥主要用于 SSH 登录。
|
||||
|
||||
## 备份GPG
|
||||
|
||||
```shell
|
||||
# 公钥
|
||||
gpg -ao public-key.pub --export <ed25519/16位>
|
||||
# 主密钥,请务必保存好!!!
|
||||
# 注意 key id 后面的 !,表示只导出这一个私钥,若没有的话默认导出全部私钥。
|
||||
gpg -ao sec-key.asc --export-secret-key <ed25519/16位>!
|
||||
# sign子密钥
|
||||
gpg -ao sign-key.asc --export-secret-key <ed25519/16位>!
|
||||
gpg -ao auth-key.asc --export-secret-key <ed25519/16位>!
|
||||
gpg -ao encr-key.asc --export-secret-key <ed25519/16位>!
|
||||
```
|
||||
|
||||
## 导入Canokey
|
||||
|
||||
```shell
|
||||
# 查看智能卡设备状态
|
||||
gpg --card-status
|
||||
# 写入GPG
|
||||
gpg --edit-key <ed25519/16位> # 为上方的sec-key
|
||||
# 选中第一个子密钥
|
||||
key 1
|
||||
# 写入到智能卡
|
||||
keytocard
|
||||
# 再次输入,取消选择
|
||||
key 1
|
||||
# 选择第二个子密钥
|
||||
key 2
|
||||
keytocard
|
||||
key 2
|
||||
key 3
|
||||
keytocard
|
||||
# 保存修改并退出
|
||||
save
|
||||
|
||||
#再次查看设备状态,可以看到此时子密钥标识符为 ssb>,表示本地只有一个指向 card-no: F1D0 xxxxxxxx 智能卡的指针,已不存在私钥。现在可以删除掉主密钥了,请再次确认你已安全备份好主密钥。
|
||||
gpg --card-status
|
||||
```
|
||||
## 删除本地密钥
|
||||
|
||||
```shell
|
||||
gpg --delete-secret-keys <ed25519/16位> # 为上方的sec-key
|
||||
```
|
||||
|
||||
为确保安全,也可直接删除 gpg 的工作目录:`%APPDATA%\gnupg`,Linux/macOS: `~/.gunpg`。
|
||||
|
||||
## 使用 Canokey
|
||||
|
||||
此时切换回日常使用的环境,首先导入公钥
|
||||
|
||||
```shell
|
||||
gpg --import public-key.pub
|
||||
```
|
||||
|
||||
然后设置子密钥指向 Canokey
|
||||
|
||||
```shell
|
||||
gpg --edit-card
|
||||
gpg/card> fetch
|
||||
```
|
||||
|
||||
此时查看本地的私钥,可以看到已经指向了 Canokey
|
||||
|
||||
```
|
||||
gpg --fingerprint --keyid-format long -K
|
||||
```
|
||||
|
||||
配置gpg路径
|
||||
|
||||
```bash
|
||||
git config --global gpg.program "C:\Program Files (x86)\GnuPG\bin\gpg.exe" --replace-all
|
||||
```
|
||||
|
||||
## Git Commit 签名
|
||||
|
||||
首先确保 Git 本地配置以及 GitHub 中的邮箱信息包含在 `UID` 中,然后设置 Git 来指定使用子密钥中的签名(S)密钥。
|
||||
|
||||
```shell
|
||||
git config --global user.signingkey <ed25519/16位> # 为上方的Sign密钥
|
||||
```
|
||||
|
||||
之后在 `git commit` 时增加 `-S` 参数即可使用 gpg 进行签名。也可在配置中设置自动 gpg 签名,此处不建议全局开启该选项,因为有的脚本可能会使用 `git am` 之类的涉及到 `commit` 的命令,如果全局开启的话会导致问题。
|
||||
|
||||
```shell
|
||||
git config commit.gpgsign true
|
||||
```
|
||||
|
||||
如果提交到 GitHub,前往 [GitHub SSH and GPG keys](https://github.com/settings/keys) 添加公钥。此处添加后,可以直接通过对应 GitHub ID 来获取公钥:`https://github.com/<yourid>.gpg`
|
||||
|
||||
## PIV
|
||||
|
||||
首先在Web端添加自己的私钥到智能卡,之后前往 [WinCrypt SSH Agent](https://github.com/buptczq/WinCryptSSHAgent) 下载并运行,此时查看 `ssh-agent` 读取到的公钥信息,把输出的公钥信息添加到服务器的 `~/.ssh/authorized_keys`
|
||||
|
||||
```shell
|
||||
# 设置环境池
|
||||
$Env:SSH_AUTH_SOCK="\\.\pipe\openssh-ssh-agent"
|
||||
# 查看ssh列表
|
||||
ssh-add -L
|
||||
```
|
||||
|
||||
此时连接 `ssh user@host`,会弹出提示输入 `PIN` 的页面,注意此时输入的是 `PIV Applet PIN`,输入后即可成功连接服务器。
|
||||
|
||||
```yaml
|
||||
tips: 可能会出现权限不够的情况,需要禁用Windows服务OpenSSH Authentication Agent
|
||||
```
|
||||
|
||||
最后可以把该程序快捷方式添加到启动目录 `%AppData%\Microsoft\Windows\Start Menu\Programs\Startup`,方便直接使用。
|
||||
67
backend/content/posts/ffmpeg.md
Normal file
67
backend/content/posts/ffmpeg.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "如何使用FFmpeg处理音视频文件"
|
||||
description: 本文提供了FFmpeg处理音视频文件的完整指南,包括将单张图片转换为视频、拼接多个视频、设置转场特效等多种操作。
|
||||
date: 2022-07-25T14:05:04+08:00
|
||||
draft: true
|
||||
slug: ffmpeg
|
||||
image:
|
||||
categories: ffmpeg
|
||||
tags: ffmpeg
|
||||
---
|
||||
|
||||
# `ffmpeg`图片转视频
|
||||
|
||||
使用单张图片生成5秒视频
|
||||
|
||||
```bash
|
||||
# -loop 1 指定开启单帧图片loop
|
||||
# -t 5 指定loop时长为5秒
|
||||
# -i input 指定输入图片文件路径 示例:pic.jpg
|
||||
# -pix_fmt 指定编码格式为yuv420p
|
||||
# -y 若输出文件已存在,则强制进行覆盖。
|
||||
# ffmpeg会根据输出文件后缀,自动选择编码格式。
|
||||
# 也可以使用 -f 指定输出格式
|
||||
ffmpeg -loop 1 -t 5 -i <filename>.jpg -pix_fmt yuv420p -y output.ts
|
||||
```
|
||||
|
||||
# `ffmpeg`拼接视频
|
||||
|
||||
```bash
|
||||
# windows
|
||||
# -i input 指定需要合并的文件,使用concat进行合并.示例:"concat:0.ts|1.ts|2.ts"
|
||||
# -vcodec 指定视频编码器的参数为copy
|
||||
# -acodec 指定音频编码器的参数为copy
|
||||
# -y 若输出文件已存在,则强制进行覆盖。
|
||||
ffmpeg -i "concat:0.ts|1.ts" -vcodec copy -acodec copy -y output.ts
|
||||
```
|
||||
|
||||
# `ffmpeg`设置转场特效
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
ffmpeg -i v0.mp4 -i v1.mp4 -i v2.mp4 -i v3.mp4 -i v4.mp4 -filter_complex \
|
||||
"[0][1:v]xfade=transition=fade:duration=1:offset=3[vfade1]; \
|
||||
[vfade1][2:v]xfade=transition=fade:duration=1:offset=10[vfade2]; \
|
||||
[vfade2][3:v]xfade=transition=fade:duration=1:offset=21[vfade3]; \
|
||||
[vfade3][4:v]xfade=transition=fade:duration=1:offset=25,format=yuv420p; \
|
||||
[0:a][1:a]acrossfade=d=1[afade1]; \
|
||||
[afade1][2:a]acrossfade=d=1[afade2]; \
|
||||
[afade2][3:a]acrossfade=d=1[afade3]; \
|
||||
[afade3][4:a]acrossfade=d=1" \
|
||||
-movflags +faststart out.mp4
|
||||
```
|
||||
|
||||
| 输入文件 | 输入文件的视频总长 | + | previous xfade `offset` | - | xfade `duration` | `offset` = |
|
||||
| :------- | :----------------- | :--: | :---------------------- | :--: | :--------------- | :--------- |
|
||||
| `v0.mp4` | 4 | + | 0 | - | 1 | 3 |
|
||||
| `v1.mp4` | 8 | + | 3 | - | 1 | 10 |
|
||||
| `v2.mp4` | 12 | + | 10 | - | 1 | 21 |
|
||||
| `v3.mp4` | 5 | + | 21 | - | 1 | 25 |
|
||||
|
||||
// 将音频转为单声道
|
||||
|
||||
```
|
||||
ffmpeg -i .\1.mp3 -ac 1 -ar 44100 -ab 16k -vol 50 -f 1s.mp3
|
||||
ffmpeg -i one.ts -i 1s.mp3 -map 0:v -map 1:a -c:v copy -shortest -af apad -y one1.ts
|
||||
```
|
||||
|
||||
121
backend/content/posts/go-arm.md
Normal file
121
backend/content/posts/go-arm.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: "使用arm交叉编译工具并解决GLIBC版本不匹配的问题"
|
||||
description: 介绍如何使用arm交叉编译工具来编译Go程序,并解决在arm平台上运行时出现GLIBC版本不匹配的问题。
|
||||
date: 2022-06-10T15:00:26+08:00
|
||||
draft: false
|
||||
slug: go-arm
|
||||
image:
|
||||
categories:
|
||||
- Go
|
||||
tags:
|
||||
- Arm
|
||||
- Go
|
||||
- GLIBC
|
||||
---
|
||||
|
||||
1. 下载 ARM 交叉编译工具,可以从官方网站下载。比如,可以从如下链接下载 GNU 工具链:[https://developer.arm.com/downloads/-/gnu-a](https://developer.arm.com/downloads/-/gnu-a)
|
||||
|
||||
示例:https://developer.arm.com/-/media/Files/downloads/gnu-a/10.3-2021.07/binrel/gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-elf.tar.xz
|
||||
|
||||
2. 设置 Go ARM 交叉编译环境变量。具体来说,需要设置以下变量:
|
||||
|
||||
```ruby
|
||||
$env:GOOS="linux"
|
||||
$env:GOARCH="arm64"
|
||||
$env:CGO_ENABLED=1
|
||||
$env:CC="D:\arm\gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-linux-gnu\bin\aarch64-none-linux-gnu-gcc.exe"
|
||||
$env:CXX="D:\arm\gcc-arm-10.3-2021.07-mingw-w64-i686-aarch64-none-linux-gnu\bin\aarch64-none-linux-gnu-g++.exe"
|
||||
```
|
||||
|
||||
3. 在 ARM 上运行程序时可能会出现如下错误:
|
||||
|
||||
```bash
|
||||
./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.28' not found (required by ./bupload)
|
||||
./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.32' not found (required by ./bupload)
|
||||
./bupload: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found (required by ./bupload)
|
||||
```
|
||||
|
||||
这是因为程序需要使用较新版本的 GLIBC 库,而 ARM 上安装的库版本较旧。可以通过以下步骤来解决这个问题:
|
||||
|
||||
4. 查看当前系统中 libc 库所支持的版本:
|
||||
|
||||
```bash
|
||||
strings /lib/aarch64-linux-gnu/libc.so.6 | grep GLIBC_
|
||||
```
|
||||
|
||||
5. 备份整个 `/lib` 目录和 `/usr/include` 目录,以便稍后还原。
|
||||
6. 从 GNU libc 官方网站下载对应版本的 libc 库。例如,可以从如下链接下载 2.35 版本的 libc 库:[http://ftp.gnu.org/gnu/glibc/glibc-2.35.tar.xz](http://ftp.gnu.org/gnu/glibc/glibc-2.35.tar.xz)
|
||||
7. 解压 libc 库:
|
||||
|
||||
```
|
||||
xz -d glibc-2.35.tar.xz
|
||||
tar xvf glibc-2.35.tar glibc-2.35
|
||||
```
|
||||
|
||||
8. 创建并进入 build 目录:
|
||||
|
||||
```bash
|
||||
mkdir build
|
||||
cd build
|
||||
```
|
||||
|
||||
9. 配置 libc 库的安装选项:
|
||||
|
||||
```javascript
|
||||
../configure --prefix=/usr --disable-profile --enable-add-ons --with-headers=/usr/include --with-binutils=/usr/bin
|
||||
```
|
||||
|
||||
10. 编译并安装 libc 库:
|
||||
|
||||
```go
|
||||
make -j4
|
||||
make install
|
||||
```
|
||||
|
||||
接下来是关于 `make` 报错的部分:
|
||||
|
||||
```yaml
|
||||
asm/errno.h: No such file or directory
|
||||
```
|
||||
|
||||
这个报错是因为 `errno.h` 文件中包含了 `asm/errno.h` 文件,但是找不到这个文件。为了解决这个问题,我们需要创建一个软链接:
|
||||
|
||||
```bash
|
||||
ln -s /usr/include/asm-generic /usr/include/asm
|
||||
```
|
||||
|
||||
然后又出现了另一个报错:
|
||||
|
||||
```bash
|
||||
/usr/include/aarch64-linux-gnu/asm/sigcontext.h: No such file or directory
|
||||
```
|
||||
|
||||
这个问题也可以通过重新安装`linux-libc-dev`后创建软链接来解决:
|
||||
|
||||
```bash
|
||||
# find / -name sigcontext.h
|
||||
sudo apt-get install --reinstall linux-libc-dev
|
||||
ln -s /usr/include/aarch64-linux-gnu/asm/sigcontext.h /usr/include/asm/sigcontext.h
|
||||
```
|
||||
|
||||
接下来,还有一个报错:
|
||||
|
||||
```yaml
|
||||
asm/sve_context.h: No such file or directory
|
||||
```
|
||||
|
||||
这个报错是因为最新的 Linux 内核在启用 ARM Scalable Vector Extension (SVE) 后,需要包含 `asm/sve_context.h` 文件。我们需要创建一个软链接来解决这个问题:
|
||||
|
||||
```bash
|
||||
# find / -name sve_context.h
|
||||
ln -s /usr/include/aarch64-linux-gnu/asm/sve_context.h /usr/include/asm/sve_context.h
|
||||
```
|
||||
|
||||
最后,还需要创建一个软链接:
|
||||
|
||||
```bash
|
||||
# find / -name byteorder.h
|
||||
ln -s /usr/include/aarch64-linux-gnu/asm/byteorder.h /usr/include/asm/byteorder.h
|
||||
```
|
||||
|
||||
完成以上步骤后,我们再次执行 `make` 命令,就应该可以顺利地编译和安装 glibc 了。
|
||||
173
backend/content/posts/go-grpc.md
Normal file
173
backend/content/posts/go-grpc.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: "Go使用gRPC进行通信"
|
||||
description: RPC是远程过程调用的简称,是分布式系统中不同节点间流行的通信方式。
|
||||
date: 2022-05-26T14:17:33+08:00
|
||||
draft: false
|
||||
slug: go-grpc
|
||||
image:
|
||||
categories:
|
||||
- Go
|
||||
tags:
|
||||
- Go
|
||||
- gRPC
|
||||
---
|
||||
|
||||
# 安装`gRPC`和`Protoc`
|
||||
|
||||
## 安装`protobuf`
|
||||
|
||||
```bash
|
||||
go get -u google.golang.org/protobuf
|
||||
go get -u google.golang.org/protobuf/proto
|
||||
go get -u google.golang.org/protobuf/protoc-gen-go
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 安装`Protoc`
|
||||
|
||||
```shell
|
||||
# 下载二进制文件并添加至环境变量
|
||||
https://github.com/protocolbuffers/protobuf/releases
|
||||
```
|
||||
|
||||
安装`Protoc`插件`protoc-gen-go`
|
||||
|
||||
```shell
|
||||
# go install 会自动编译项目并添加至环境变量中
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
||||
```
|
||||
|
||||
```shell
|
||||
#protoc-gen-go 文档地址
|
||||
https://developers.google.com/protocol-buffers/docs/reference/go-generated
|
||||
```
|
||||
|
||||
# 创建`proto`文件并定义服务
|
||||
|
||||
## 新建 `task.proto`文件
|
||||
|
||||
```shell
|
||||
touch task.proto
|
||||
```
|
||||
|
||||
## 编写`task.proto`
|
||||
|
||||
```protobuf
|
||||
// 指定proto版本
|
||||
syntax = "proto3";
|
||||
// 指定包名
|
||||
package task;
|
||||
// 指定输出 go 语言的源码到哪个目录和 包名
|
||||
// 主要 目录和包名用 ; 隔开
|
||||
// 将在当前目录生成 task.pb.go
|
||||
// 也可以只填写 "./",会生成的包名会变成 "----"
|
||||
option go_package = "./;task";
|
||||
|
||||
// 指定RPC的服务名
|
||||
service TaskService {
|
||||
// 调用 AddTaskCompletion 方法
|
||||
rpc AddTaskCompletion(request) returns (response);
|
||||
}
|
||||
|
||||
// RPC TaskService服务,AddTaskCompletion函数的请求参数,即消息
|
||||
message request {
|
||||
uint32 id = 1;//任务id
|
||||
string module = 2;//所属模块
|
||||
int32 value = 3;//此次完成值
|
||||
string guid = 4;//用户id
|
||||
}
|
||||
// RPC TaskService服务,TaskService函数的返回值,即消息
|
||||
message response{
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
## 使用`Protoc`来生成Go代码
|
||||
|
||||
```bash
|
||||
protoc --go_out=. --go-grpc_out=. <要进行生成代码的文件>.proto
|
||||
# example
|
||||
protoc --go_out=. --go-grpc_out=. .\task.proto
|
||||
```
|
||||
|
||||
这样生成会生成两个`.go`文件,一个是对应消息`task.pb.go`,一个对应服务接口`task_grpc.pb.go`。
|
||||
|
||||
在`task_grpc.pb.go`中,在我们定义的服务接口中,多增加了一个私有的接口方法:
|
||||
`mustEmbedUnimplementedTaskServiceServer()`
|
||||
|
||||
# 使用`Go`监听`gRPC`服务端及客户端
|
||||
|
||||
## 监听服务端
|
||||
|
||||
并有生成的一个`UnimplementedTaskServiceServer`结构体来实现了所有的服务接口。因此,在我们自己实现的服务类中,需要继承这个结构体,如:
|
||||
|
||||
```go
|
||||
// 用于实现grpc服务 TaskServiceServer 接口
|
||||
type TaskServiceImpl struct {
|
||||
// 需要继承结构体 UnimplementedServiceServer 或mustEmbedUnimplementedTaskServiceServer
|
||||
task.mustEmbedUnimplementedTaskServiceServer()
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 创建Grpc服务
|
||||
// 创建tcp连接
|
||||
listener, err := net.Listen("tcp", ":8082")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// 创建grpc服务
|
||||
grpcServer := grpc.NewServer()
|
||||
// 此函数在task.pb.go中,自动生成
|
||||
task.RegisterTaskServiceServer(grpcServer, &TaskServiceImpl{})
|
||||
// 在grpc服务上注册反射服务
|
||||
reflection.Register(grpcServer)
|
||||
// 启动grpc服务
|
||||
err = grpcServer.Serve(listener)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *TaskServiceImpl) AddTaskCompletion(ctx context.Context, in *task.Request) (*task.Response, error) {
|
||||
fmt.Println("收到一个Grpc 请求, 请求参数为", in.Guid)
|
||||
r := &task.Response{
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后在`TaskService`上实现我们的服务接口。
|
||||
|
||||
|
||||
## 客户端
|
||||
|
||||
```go
|
||||
conn, err := grpc.Dial("127.0.0.1:8082", grpc.WithInsecure())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// 创建grpc客户端
|
||||
client := task.NewTaskServiceClient(conn)
|
||||
// 创建请求
|
||||
req := &task.Request{
|
||||
Id: 1,
|
||||
Module: "test",
|
||||
Value: 3,
|
||||
Guid: "test",
|
||||
}
|
||||
// 调用rpc TaskService AddTaskCompletion函数
|
||||
response, err := client.AddTaskCompletion(context.Background(), req)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
log.Println(response)
|
||||
```
|
||||
|
||||
[本文参考](https://www.cnblogs.com/whuanle/p/14588031.html)
|
||||
98
backend/content/posts/go-xml.md
Normal file
98
backend/content/posts/go-xml.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: "Go语言解析Xml"
|
||||
slug: "go-xml"
|
||||
date: 2022-05-20T14:38:05+08:00
|
||||
draft: false
|
||||
description: "使用Go简简单单的解析Xml!"
|
||||
tags:
|
||||
- Go
|
||||
- Xml
|
||||
categories:
|
||||
- Go
|
||||
---
|
||||
|
||||
# 开始之前
|
||||
|
||||
```go
|
||||
import "encoding/xml"
|
||||
```
|
||||
|
||||
## 简单的`Xml`解析
|
||||
|
||||
### 1.假设我们解析的`Xml`内容如下:
|
||||
|
||||
```xml
|
||||
<feed>
|
||||
<person name="initcool" id="1" age=18 />
|
||||
</feed>
|
||||
```
|
||||
|
||||
<!--more-->
|
||||
|
||||
### 2.接着我们构造对应的结构体
|
||||
|
||||
```go
|
||||
type Feed struct {
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Person struct{
|
||||
Name string `xml:"name"`
|
||||
Id string `xml:"id"`
|
||||
Age int `xml:"age"`
|
||||
} `xml:"person"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3.对`Xml`数据进行反序列化
|
||||
|
||||
```go
|
||||
var feed Feed
|
||||
|
||||
// 读取Xml文件,并返回字节流
|
||||
content,err := ioutil.ReadFile(XmlFilename)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 将读取到的内容反序列化到feed
|
||||
xml.Unmarshal(content,&feed)
|
||||
```
|
||||
|
||||
## 带有命名空间的`Xml`解析
|
||||
|
||||
部分`xml`文件会带有`命名空间`(`Namespace`),也就是冒号左侧的内容,此时我们需要在`go`结构体的`tag` 中加入`命名空间`。
|
||||
|
||||
### 1.带有命名空间(Namespace)的`Xml`文件
|
||||
|
||||
```xml
|
||||
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
|
||||
<!-- yt即是命名空间 -->
|
||||
<yt:videoId>XXXXXXX</yt:videoId>
|
||||
<!-- media是另一个命名空间 -->
|
||||
<media:community></media:community>
|
||||
</feed>
|
||||
```
|
||||
|
||||
### 2.针对命名空间构造结构体
|
||||
|
||||
```go
|
||||
type Feed struct {
|
||||
XMLName xml.Name `xml:"feed"` // 指定最外层的标签为feed
|
||||
VideoId string `xml:"http://www.youtube.com/xml/schemas/2015 videoId"`
|
||||
Community string `xml:"http://search.yahoo.com/mrss/ community"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3.对`Xml`数据进行反序列化
|
||||
|
||||
```go
|
||||
var feed Feed
|
||||
|
||||
// 读取Xml文件,并返回字节流
|
||||
content,err := ioutil.ReadFile(XmlFilename)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 将读取到的内容反序列化到feed
|
||||
xml.Unmarshal(content,&feed)
|
||||
```
|
||||
36
backend/content/posts/hugo.md
Normal file
36
backend/content/posts/hugo.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Hugo使用指南!"
|
||||
slug: "hugo"
|
||||
draft: false
|
||||
date: 2022-05-20T10:23:53+08:00
|
||||
description: "快速上手hugo!"
|
||||
tags:
|
||||
- Go
|
||||
- Hugo
|
||||
categories:
|
||||
- Go
|
||||
---
|
||||
查看Hugo版本号
|
||||
|
||||
```bash
|
||||
hugo version
|
||||
```
|
||||
|
||||
新建一个Hugo页面
|
||||
|
||||
```
|
||||
hugo new site <siteName>
|
||||
```
|
||||
|
||||
设置主题
|
||||
|
||||
```bash
|
||||
cd <siteName>
|
||||
git init
|
||||
|
||||
# 设置为 Stack主题
|
||||
git clone https://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack
|
||||
git submodule add https://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack
|
||||
```
|
||||
|
||||
部署Hugo到github
|
||||
67
backend/content/posts/linux-dhcp.md
Normal file
67
backend/content/posts/linux-dhcp.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Linux部署DHCP服务"
|
||||
description: Debian下使用docker镜像部署DHCP服务
|
||||
date: 2022-05-23T11:11:40+08:00
|
||||
draft: false
|
||||
slug: linux-dhcp
|
||||
image:
|
||||
categories: Linux
|
||||
tags:
|
||||
- Linux
|
||||
- DHCP
|
||||
---
|
||||
|
||||
拉取`networkboot/dhcpd`镜像
|
||||
|
||||
```shell
|
||||
docker pull networkboot/dhcpd
|
||||
```
|
||||
|
||||
新建`data/dhcpd.conf`文件
|
||||
|
||||
```shell
|
||||
touch /data/dhcpd.conf
|
||||
```
|
||||
|
||||
修改`data/dhcpd.conf`文件
|
||||
|
||||
```
|
||||
subnet 204.254.239.0 netmask 255.255.255.224 {
|
||||
option subnet-mask 255.255.0.0;
|
||||
option domain-name "cname.nmslwsnd.com";
|
||||
option domain-name-servers 8.8.8.8;
|
||||
range 204.254.239.10 204.254.239.30;
|
||||
}
|
||||
```
|
||||
|
||||
修改`/etc/network/interfaces`
|
||||
|
||||
```
|
||||
# The loopback network interface (always required)
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
# Get our IP address from any DHCP server
|
||||
auto dhcp
|
||||
iface dhcp inet static
|
||||
address 204.254.239.0
|
||||
netmask 255.255.255.224
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
获取帮助命令
|
||||
|
||||
```shell
|
||||
docker run -it --rm networkboot/dhcpd man dhcpd.conf
|
||||
```
|
||||
|
||||
运行`DHCP`服务
|
||||
|
||||
```shell
|
||||
docker run -it --rm --init --net host -v "/data":/data networkboot/dhcpd <网卡名称>
|
||||
# 示例
|
||||
docker run -it --rm --init --net host -v "/data":/data networkboot/dhcpd dhcp
|
||||
```
|
||||
|
||||
36
backend/content/posts/linux-shell.md
Normal file
36
backend/content/posts/linux-shell.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Linux Shell"
|
||||
description:
|
||||
date: 2022-05-21T10:02:09+08:00
|
||||
draft: false
|
||||
Hidden: true
|
||||
slug: linux-shell
|
||||
image:
|
||||
categories:
|
||||
Linux
|
||||
tag:
|
||||
Linux
|
||||
Shell
|
||||
---
|
||||
|
||||
Linux守护进程:no_good:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# nohup.sh
|
||||
while true
|
||||
do
|
||||
# -f 后跟进程名,判断进程是否正在运行
|
||||
if [ `pgrep -f <ProcessName> | wc -l` -eq 0 ];then
|
||||
echo "进程已终止"
|
||||
push
|
||||
# /dev/null 无输出日志
|
||||
nohup ./<ProcessName> > /dev/null 2>&1 &
|
||||
else
|
||||
echo "进程正在运行"
|
||||
fi
|
||||
# 每隔1分钟检查一次
|
||||
sleep 1m
|
||||
done
|
||||
```
|
||||
|
||||
65
backend/content/posts/linux.md
Normal file
65
backend/content/posts/linux.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: "Linux"
|
||||
description:
|
||||
date: 2022-09-08T15:19:00+08:00
|
||||
draft: true
|
||||
slug: linux
|
||||
image:
|
||||
categories:
|
||||
- Linux
|
||||
tags:
|
||||
- Linux
|
||||
---
|
||||
|
||||
```bash
|
||||
# 使用cd 进入到上一个目录
|
||||
cd -
|
||||
```
|
||||
|
||||
复制和粘贴
|
||||
|
||||
```bash
|
||||
ctrl + shift + c
|
||||
ctrl + shift + v
|
||||
```
|
||||
|
||||
|
||||
|
||||
快速移动
|
||||
|
||||
```bash
|
||||
# 移动到行首
|
||||
ctrl + a
|
||||
# 移动到行尾
|
||||
ctrl + e
|
||||
```
|
||||
|
||||
快速删除
|
||||
|
||||
```bash
|
||||
# 删除光标之前的内容
|
||||
ctrl + u
|
||||
# 删除光标之后的内容
|
||||
ctrl + k
|
||||
# 恢复之前删除的内容
|
||||
ctrl + y
|
||||
```
|
||||
|
||||
不适用cat
|
||||
|
||||
```
|
||||
使用less 查看 顶部的文件
|
||||
less filename
|
||||
```
|
||||
|
||||
使用alt+backspace删除,以单词为单位
|
||||
|
||||
```
|
||||
tcpdump host 1.1.1.1
|
||||
```
|
||||
|
||||
```
|
||||
# 并行执行命令 Parallel
|
||||
find . -type f -name '*.html' -print | parallel gzip
|
||||
```
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
title: Loco.rs Backend Framework
|
||||
slug: loco-rs-framework
|
||||
description: An introduction to Loco.rs, the Rails-inspired web framework for Rust.
|
||||
category: tech
|
||||
post_type: article
|
||||
pinned: false
|
||||
published: true
|
||||
tags:
|
||||
- rust
|
||||
- loco-rs
|
||||
- backend
|
||||
- api
|
||||
---
|
||||
|
||||
# Introduction to Loco.rs
|
||||
|
||||
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||
|
||||
## Features
|
||||
|
||||
- MVC Architecture
|
||||
- SeaORM Integration
|
||||
- Background Jobs
|
||||
- Authentication
|
||||
- CLI Generator
|
||||
|
||||
## 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.
|
||||
569
backend/content/posts/mysql.md
Normal file
569
backend/content/posts/mysql.md
Normal file
@@ -0,0 +1,569 @@
|
||||
---
|
||||
title: "mysql个人常用命令及操作"
|
||||
description:
|
||||
date: 2021-09-21T16:13:24+08:00
|
||||
draft: true
|
||||
slug: mysql
|
||||
image:
|
||||
categories:
|
||||
- Database
|
||||
tags:
|
||||
- Linux
|
||||
- Mysql
|
||||
- Sql
|
||||
---
|
||||
|
||||
启动`mysql`
|
||||
|
||||
```bash
|
||||
sudo service mysql start
|
||||
```
|
||||
|
||||
使用`root`账户登录`mysql`
|
||||
|
||||
```bash
|
||||
sudo mysql -u root
|
||||
```
|
||||
|
||||
查看数据库信息
|
||||
|
||||
```mysql
|
||||
show databases;
|
||||
```
|
||||
|
||||
新增数据库
|
||||
|
||||
```mysql
|
||||
create database <新增的数据库名>;
|
||||
# 示例,新增一个名为gradesystem的数据库
|
||||
create database gradesystem;
|
||||
|
||||
```
|
||||
|
||||
切换数据库
|
||||
|
||||
```mysql
|
||||
use <切换的数据库名>;
|
||||
# 示例,切换至gradesystem数据库
|
||||
use gradesystem;
|
||||
```
|
||||
|
||||
查看数据库中的表
|
||||
|
||||
```mysql
|
||||
# 查看数据库中所有的表
|
||||
show tables;
|
||||
```
|
||||
|
||||
新增表
|
||||
|
||||
```mysql
|
||||
# MySQL不区分大小写
|
||||
CREATE TABLE student(
|
||||
sid int NOT NULL AUTO_INCREMENT,
|
||||
sname varchar(20) NOT NULL,
|
||||
gender varchar(10) NOT NULL,
|
||||
PRIMARY KEY(sid)
|
||||
);
|
||||
# 新增一个表名为学生的表。
|
||||
# AUTO_INCREMENT, 自动地创建主键字段的值。
|
||||
# PRIMARY KEY(sid) 设置主键为sid
|
||||
CREATE TABLE course(
|
||||
cid int not null auto_increment,
|
||||
cname varchar(20) not null,
|
||||
primary key(cid)
|
||||
);
|
||||
# 新增一个表名为课程的表。
|
||||
# primary key(cid) 设置主键为cid
|
||||
|
||||
CREATE TABLE mark(
|
||||
mid int not null auto_increment,
|
||||
sid int not null,
|
||||
cid int not null,
|
||||
score int not null,
|
||||
primary key(mid),
|
||||
foreign key(sid) references student(sid),
|
||||
foreign key(cid) references course(cid)
|
||||
);
|
||||
# 新增一个表明为mark的表
|
||||
# primary key(cid) 设置主键为cid
|
||||
# foreign 设置外键为sid
|
||||
# foreign 设置外键为cid
|
||||
|
||||
insert into student values(1,'Tom','male'),(2,'Jack','male'),(3,'Rose','female');
|
||||
# 向student表插入数据,sid为1,sname为'Tom',gender为'male'
|
||||
|
||||
insert into course values(1,'math'),(2,'physics'),(3,'chemistry');
|
||||
# 向course表插入数据,sid为1,cname为'math'
|
||||
|
||||
insert into mark values(1,1,1,80);
|
||||
# 向mark表插入数据,mid为1,sid为1,cid为1,score为80
|
||||
```
|
||||
|
||||
### 向数据库插入数据
|
||||
|
||||
```mysql
|
||||
source <数据库文件所在目录>
|
||||
|
||||
|
||||
```
|
||||
|
||||
## SELECT语句查询
|
||||
|
||||
SELECT 语句的基本格式为:
|
||||
|
||||
```bash
|
||||
SELECT 要查询的列名 FROM 表名字 WHERE 限制条件;
|
||||
```
|
||||
|
||||
```mysql
|
||||
select name,age from employee;
|
||||
# 查看employee的name列和age列
|
||||
|
||||
select name,age from employee where age > 25;
|
||||
# 筛选出age 大于25的结果
|
||||
|
||||
select name,age,phone from employee where name = 'Mary';
|
||||
# 筛选出name为'Mary'的name,age,phone
|
||||
|
||||
select name,age,phone from employee where age < 25 or age >30;
|
||||
# 筛选出age小于30或大于25的name,age,phone
|
||||
|
||||
select name,age,phone from employee where age > 25 and age < 30;
|
||||
# 筛选出age大于25且小于30的name,age,phone
|
||||
|
||||
select name,age,phone from employee where age between 25 and 30;
|
||||
# 筛选出包含25和30的,name,age,phone
|
||||
|
||||
select name,age,phone,in_dpt from employee where in_dpt in('dpt3','dpt4');
|
||||
# 筛选出在dpt3或dpt4里面的name,age,phone,in_dpt
|
||||
|
||||
select name,age,phone,in_dpt from employee where in_dpt not in('dpt1','dpt3');
|
||||
# 筛选出不在dpt1和dpt3的name,age,phone,in_dpt
|
||||
|
||||
|
||||
```
|
||||
|
||||
## 通配符
|
||||
|
||||
关键字 **LIKE** 可用于实现模糊查询,常见于搜索功能中。
|
||||
|
||||
和 LIKE 联用的通常还有通配符,代表未知字符。SQL 中的通配符是 `_` 和 `%` 。其中 `_` 代表一个**未指定**字符,`%` 代表**不定个**未指定字符
|
||||
|
||||
```mysql
|
||||
select name,age,phone from employee where phone like '1101__';
|
||||
# 筛选出1101开头的六位数字的name,age,phone
|
||||
|
||||
select name,age,phone from employee where name like 'J%';
|
||||
# 筛选出name位J开头的人的name,age,phone
|
||||
```
|
||||
|
||||
## 排序
|
||||
|
||||
为了使查询结果看起来更顺眼,我们可能需要对结果按某一列来排序,这就要用到 **ORDER BY** 排序关键词。默认情况下,**ORDER BY** 的结果是**升序**排列,而使用关键词 **ASC** 和 **DESC** 可指定**升序**或**降序**排序。 比如,我们**按 salary 降序排列**,SQL 语句为
|
||||
|
||||
```mysql
|
||||
select name,age,salary,phone from employee order by salary desc;
|
||||
# salary列按降序排列
|
||||
select name,age,salary,phone from employee order by salary;
|
||||
# 不加 DESC 或 ASC 将默认按照升序排列。
|
||||
```
|
||||
|
||||
## SQL 内置函数和计算
|
||||
|
||||
置函数,这些函数都对 SELECT 的结果做操作:
|
||||
|
||||
| 函数名: | COUNT | SUM | AVG | MAX | MIN |
|
||||
| -------- | ----- | ---- | -------- | ------ | ------ |
|
||||
| 作用: | 计数 | 求和 | 求平均值 | 最大值 | 最小值 |
|
||||
|
||||
> 其中 COUNT 函数可用于任何数据类型(因为它只是计数),而 SUM 、AVG 函数都只能对数字类数据类型做计算,MAX 和 MIN 可用于数值、字符串或是日期时间数据类型。
|
||||
|
||||
|
||||
|
||||
```mysql
|
||||
select max(salary) as max_salary,min(salary) from employee;
|
||||
# 使用as关键字可以给值重命名,
|
||||
```
|
||||
|
||||
## 连接查询
|
||||
|
||||
在处理多个表时,子查询只有在结果来自一个表时才有用。但如果需要显示两个表或多个表中的数据,这时就必须使用连接 **(join)** 操作。 连接的基本思想是把两个或多个表当作一个新的表来操作,如下:
|
||||
|
||||
```mysql
|
||||
select id,name,people_num from employee,department where employee.in_dpt = department.dpt_name order by id;
|
||||
# 这条语句查询出的是,各员工所在部门的人数,其中员工的 id 和 name 来自 employee 表,people_num 来自 department 表:
|
||||
|
||||
select id,name,people_num from employee join department on employee.in_dpt = department.dpt_name order by id;
|
||||
# 另一个连接语句格式是使用 JOIN ON 语法,刚才的语句等同于以上语句
|
||||
```
|
||||
|
||||
## 删除数据库
|
||||
|
||||
```mysql
|
||||
drop database test_01;
|
||||
# 删除名为test_01的数据库;
|
||||
```
|
||||
|
||||
### 修改表
|
||||
|
||||
重命名一张表的语句有多种形式,以下 3 种格式效果是一样的:
|
||||
|
||||
```sql
|
||||
RENAME TABLE 原名 TO 新名字;
|
||||
|
||||
ALTER TABLE 原名 RENAME 新名;
|
||||
|
||||
ALTER TABLE 原名 RENAME TO 新名;
|
||||
```
|
||||
|
||||
进入数据库 mysql_shiyan :
|
||||
|
||||
```mysql
|
||||
use mysql_shiyan
|
||||
```
|
||||
|
||||
使用命令尝试修改 `table_1` 的名字为 `table_2` :
|
||||
|
||||
```mysql
|
||||
RENAME TABLE table_1 TO table_2;
|
||||
```
|
||||
|
||||
删除一张表的语句,类似于刚才用过的删除数据库的语句,格式是这样的:
|
||||
|
||||
```sql
|
||||
DROP TABLE 表名字;
|
||||
```
|
||||
|
||||
比如我们把 `table_2` 表删除:
|
||||
|
||||
```mysql
|
||||
DROP TABLE table_2;
|
||||
```
|
||||
|
||||
#### 增加一列
|
||||
|
||||
在表中增加一列的语句格式为:
|
||||
|
||||
```sql
|
||||
ALTER TABLE 表名字 ADD COLUMN 列名字 数据类型 约束;
|
||||
或:
|
||||
ALTER TABLE 表名字 ADD 列名字 数据类型 约束;
|
||||
```
|
||||
|
||||
现在 employee 表中有 `id、name、age、salary、phone、in_dpt` 这 6 个列,我们尝试加入 `height` (身高)一个列并指定 DEFAULT 约束:
|
||||
|
||||
```mysql
|
||||
ALTER TABLE employee ADD height INT(4) DEFAULT 170;
|
||||
```
|
||||
|
||||
可以发现:新增加的列,被默认放置在这张表的最右边。如果要把增加的列插入在指定位置,则需要在语句的最后使用 AFTER 关键词(**“AFTER 列 1” 表示新增的列被放置在 “列 1” 的后面**)。
|
||||
|
||||
> 提醒:语句中的 INT(4) 不是表示整数的字节数,而是表示该值的显示宽度,如果设置填充字符为 0,则 170 显示为 0170
|
||||
|
||||
比如我们新增一列 `weight`(体重) 放置在 `age`(年龄) 的后面:
|
||||
|
||||
```mysql
|
||||
ALTER TABLE employee ADD weight INT(4) DEFAULT 120 AFTER age;
|
||||
```
|
||||
|
||||
|
||||
|
||||
上面的效果是把新增的列加在某位置的后面,如果想放在第一列的位置,则使用 `FIRST` 关键词,如语句:
|
||||
|
||||
```sql
|
||||
ALTER TABLE employee ADD test INT(10) DEFAULT 11 FIRST;
|
||||
```
|
||||
|
||||
#### 删除一列
|
||||
|
||||
删除表中的一列和刚才使用的新增一列的语句格式十分相似,只是把关键词 `ADD` 改为 `DROP` ,语句后面不需要有数据类型、约束或位置信息。具体语句格式:
|
||||
|
||||
```sql
|
||||
ALTER TABLE 表名字 DROP COLUMN 列名字;
|
||||
|
||||
或: ALTER TABLE 表名字 DROP 列名字;
|
||||
```
|
||||
|
||||
我们把刚才新增的 `test` 删除:
|
||||
|
||||
```sql
|
||||
ALTER TABLE employee DROP test;
|
||||
```
|
||||
|
||||
#### 重命名一列
|
||||
|
||||
这条语句其实不只可用于重命名一列,准确地说,它是对一个列做修改(CHANGE) :
|
||||
|
||||
```sql
|
||||
ALTER TABLE 表名字 CHANGE 原列名 新列名 数据类型 约束;
|
||||
```
|
||||
|
||||
> **注意:这条重命名语句后面的 “数据类型” 不能省略,否则重命名失败。**
|
||||
|
||||
当**原列名**和**新列名**相同的时候,指定新的**数据类型**或**约束**,就可以用于修改数据类型或约束。需要注意的是,修改数据类型可能会导致数据丢失,所以要慎重使用。
|
||||
|
||||
我们用这条语句将 “height” 一列重命名为汉语拼音 “shengao” ,效果如下:
|
||||
|
||||
```mysql
|
||||
ALTER TABLE employee CHANGE height shengao INT(4) DEFAULT 170;
|
||||
```
|
||||
|
||||
#### 改变数据类型
|
||||
|
||||
要修改一列的数据类型,除了使用刚才的 **CHANGE** 语句外,还可以用这样的 **MODIFY** 语句:
|
||||
|
||||
```sql
|
||||
ALTER TABLE 表名字 MODIFY 列名字 新数据类型;
|
||||
```
|
||||
|
||||
再次提醒,修改数据类型必须小心,因为这可能会导致数据丢失。在尝试修改数据类型之前,请慎重考虑。
|
||||
|
||||
#### 修改表中某个值
|
||||
|
||||
大多数时候我们需要做修改的不会是整个数据库或整张表,而是表中的某一个或几个数据,这就需要我们用下面这条命令达到精确的修改:
|
||||
|
||||
```sql
|
||||
UPDATE 表名字 SET 列1=值1,列2=值2 WHERE 条件;
|
||||
```
|
||||
|
||||
比如,我们要把 Tom 的 age 改为 21,salary 改为 3000:
|
||||
|
||||
```mysql
|
||||
UPDATE employee SET age=21,salary=3000 WHERE name='Tom';
|
||||
```
|
||||
|
||||
> **注意:一定要有 WHERE 条件,否则会出现你不想看到的后果**
|
||||
|
||||
#### 删除一行记录
|
||||
|
||||
删除表中的一行数据,也必须加上 WHERE 条件,否则整列的数据都会被删除。删除语句:
|
||||
|
||||
```sql
|
||||
DELETE FROM 表名字 WHERE 条件;
|
||||
```
|
||||
|
||||
我们尝试把 Tom 的数据删除:
|
||||
|
||||
```mysql
|
||||
DELETE FROM employee WHERE name='Tom';
|
||||
```
|
||||
|
||||
#### 索引
|
||||
|
||||
索引是一种与表有关的结构,它的作用相当于书的目录,可以根据目录中的页码快速找到所需的内容。
|
||||
|
||||
当表中有大量记录时,若要对表进行查询,没有索引的情况是全表搜索:将所有记录一一取出,和查询条件进行对比,然后返回满足条件的记录。这样做会执行大量磁盘 I/O 操作,并花费大量数据库系统时间。
|
||||
|
||||
而如果在表中已建立索引,在索引中找到符合查询条件的索引值,通过索引值就可以快速找到表中的数据,可以**大大加快查询速度**。
|
||||
|
||||
对一张表中的某个列建立索引,有以下两种语句格式:
|
||||
|
||||
```sql
|
||||
ALTER TABLE 表名字 ADD INDEX 索引名 (列名);
|
||||
|
||||
CREATE INDEX 索引名 ON 表名字 (列名);
|
||||
```
|
||||
|
||||
我们用这两种语句分别建立索引:
|
||||
|
||||
```sql
|
||||
ALTER TABLE employee ADD INDEX idx_id (id); #在employee表的id列上建立名为idx_id的索引
|
||||
|
||||
CREATE INDEX idx_name ON employee (name); #在employee表的name列上建立名为idx_name的索引
|
||||
```
|
||||
|
||||
索引的效果是加快查询速度,当表中数据不够多的时候是感受不出它的效果的。这里我们使用命令 **SHOW INDEX FROM 表名字;** 查看刚才新建的索引:
|
||||
|
||||

|
||||
|
||||
在使用 SELECT 语句查询的时候,语句中 WHERE 里面的条件,会**自动判断有没有可用的索引**。
|
||||
|
||||
比如有一个用户表,它拥有用户名(username)和个人签名(note)两个字段。其中用户名具有唯一性,并且格式具有较强的限制,我们给用户名加上一个唯一索引;个性签名格式多变,而且允许不同用户使用重复的签名,不加任何索引。
|
||||
|
||||
这时候,如果你要查找某一用户,使用语句 `select * from user where username=?` 和 `select * from user where note=?` 性能是有很大差距的,对**建立了索引的用户名**进行条件查询会比**没有索引的个性签名**条件查询快几倍,在数据量大的时候,这个差距只会更大。
|
||||
|
||||
一些字段不适合创建索引,比如性别,这个字段存在大量的重复记录无法享受索引带来的速度加成,甚至会拖累数据库,导致数据冗余和额外的 CPU 开销。
|
||||
|
||||
## 视图
|
||||
|
||||
|
||||
|
||||
视图是从一个或多个表中导出来的表,是一种**虚拟存在的表**。它就像一个窗口,通过这个窗口可以看到系统专门提供的数据,这样,用户可以不用看到整个数据库中的数据,而只关心对自己有用的数据。
|
||||
|
||||
注意理解视图是虚拟的表:
|
||||
|
||||
- 数据库中只存放了视图的定义,而没有存放视图中的数据,这些数据存放在原来的表中;
|
||||
- 使用视图查询数据时,数据库系统会从原来的表中取出对应的数据;
|
||||
- 视图中的数据依赖于原来表中的数据,一旦表中数据发生改变,显示在视图中的数据也会发生改变;
|
||||
- 在使用视图的时候,可以把它当作一张表。
|
||||
|
||||
创建视图的语句格式为:
|
||||
|
||||
```sql
|
||||
CREATE VIEW 视图名(列a,列b,列c) AS SELECT 列1,列2,列3 FROM 表名字;
|
||||
```
|
||||
|
||||
可见创建视图的语句,后半句是一个 SELECT 查询语句,所以**视图也可以建立在多张表上**,只需在 SELECT 语句中使用**子查询**或**连接查询**,这些在之前的实验已经进行过。
|
||||
|
||||
现在我们创建一个简单的视图,名为 **v_emp**,包含**v_name**,**v_age**,**v_phone**三个列:
|
||||
|
||||
```sql
|
||||
CREATE VIEW v_emp (v_name,v_age,v_phone) AS SELECT name,age,phone FROM employee;
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 导出
|
||||
|
||||
|
||||
|
||||
导出与导入是相反的过程,是把数据库某个表中的数据保存到一个文件之中。导出语句基本格式为:
|
||||
|
||||
```sql
|
||||
SELECT 列1,列2 INTO OUTFILE '文件路径和文件名' FROM 表名字;
|
||||
```
|
||||
|
||||
**注意:语句中 “文件路径” 之下不能已经有同名文件。**
|
||||
|
||||
现在我们把整个 employee 表的数据导出到 /var/lib/mysql-files/ 目录下,导出文件命名为 **out.txt** 具体语句为:
|
||||
|
||||
```sql
|
||||
SELECT * INTO OUTFILE '/var/lib/mysql-files/out.txt' FROM employee;
|
||||
```
|
||||
|
||||
用 gedit 可以查看导出文件 `/var/lib/mysql-files/out.txt` 的内容:
|
||||
|
||||
> 也可以使用 `sudo cat /var/lib/mysql-files/out.txt` 命令查看。
|
||||
|
||||
## 备份
|
||||
|
||||
|
||||
|
||||
数据库中的数据十分重要,出于安全性考虑,在数据库的使用中,应该注意使用备份功能。
|
||||
|
||||
> 备份与导出的区别:导出的文件只是保存数据库中的数据;而备份,则是把数据库的结构,包括数据、约束、索引、视图等全部另存为一个文件。
|
||||
|
||||
**mysqldump** 是 MySQL 用于备份数据库的实用程序。它主要产生一个 SQL 脚本文件,其中包含从头重新创建数据库所必需的命令 CREATE TABLE INSERT 等。
|
||||
|
||||
使用 mysqldump 备份的语句:
|
||||
|
||||
```bash
|
||||
mysqldump -u root 数据库名>备份文件名; #备份整个数据库
|
||||
|
||||
mysqldump -u root 数据库名 表名字>备份文件名; #备份整个表
|
||||
```
|
||||
|
||||
> mysqldump 是一个备份工具,因此该命令是在终端中执行的,而不是在 mysql 交互环境下
|
||||
|
||||
我们尝试备份整个数据库 `mysql_shiyan`,将备份文件命名为 `bak.sql`,先 `Ctrl+D` 退出 MySQL 控制台,再打开 Xfce 终端,在终端中输入命令:
|
||||
|
||||
```bash
|
||||
cd /home/shiyanlou/
|
||||
mysqldump -u root mysql_shiyan > bak.sql;
|
||||
```
|
||||
|
||||
使用命令 “ls” 可见已经生成备份文件 `bak.sql`:
|
||||
|
||||

|
||||
|
||||
> 你可以用 gedit 查看备份文件的内容,可以看见里面不仅保存了数据,还有所备份的数据库的其它信息。
|
||||
|
||||
## 恢复
|
||||
|
||||
|
||||
|
||||
用备份文件恢复数据库,其实我们早就使用过了。在本次实验的开始,我们使用过这样一条命令:
|
||||
|
||||
```bash
|
||||
source /tmp/SQL6/MySQL-06.sql
|
||||
```
|
||||
|
||||
这就是一条恢复语句,它把 MySQL-06.sql 文件中保存的 `mysql_shiyan` 数据库恢复。
|
||||
|
||||
还有另一种方式恢复数据库,但是在这之前我们先使用命令新建一个**空的数据库 test**:
|
||||
|
||||
```bash
|
||||
mysql -u root #因为在上一步已经退出了 MySQL,现在需要重新登录
|
||||
CREATE DATABASE test; #新建一个名为test的数据库
|
||||
```
|
||||
|
||||
再次 **Ctrl+D** 退出 MySQL,然后输入语句进行恢复,把刚才备份的 **bak.sql** 恢复到 **test** 数据库:
|
||||
|
||||
```bash
|
||||
mysql -u root test < bak.sql
|
||||
```
|
||||
|
||||
我们输入命令查看 test 数据库的表,便可验证是否恢复成功:
|
||||
|
||||
```bash
|
||||
mysql -u root # 因为在上一步已经退出了 MySQL,现在需要重新登录
|
||||
use test # 连接数据库 test
|
||||
|
||||
SHOW TABLES; # 查看 test 数据库的表
|
||||
```
|
||||
|
||||
可以看见原数据库的 4 张表和 1 个视图,现在已经恢复到 test 数据库中:
|
||||
|
||||

|
||||
|
||||
再查看 employee 表的恢复情况:
|
||||
|
||||

|
||||
|
||||
## Mysql授权
|
||||
|
||||
1. 登录MySQL:
|
||||
|
||||
```sql
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
2. 进入MySQL并查看用户和主机:
|
||||
|
||||
```sql
|
||||
use mysql;
|
||||
select host,user from user;
|
||||
```
|
||||
|
||||
3. 更新root用户允许远程连接:
|
||||
|
||||
```sql
|
||||
update user set host='%' where user='root';
|
||||
```
|
||||
|
||||
4. 设置root用户密码:
|
||||
|
||||
```sql
|
||||
alter user 'root'@'localhost' identified by 'your_password';
|
||||
```
|
||||
|
||||
注意:不要使用临时密码。
|
||||
|
||||
5. 授权允许远程访问:
|
||||
|
||||
```sql
|
||||
grant all privileges on *.* to 'root'@'%' identified by 'password';
|
||||
```
|
||||
|
||||
请将命令中的“password”更改为您的MySQL密码。
|
||||
|
||||
6. 刷新授权:
|
||||
|
||||
```sql
|
||||
flush privileges;
|
||||
```
|
||||
|
||||
7. 关闭授权:
|
||||
|
||||
```sql
|
||||
revoke all on *.* from dba@localhost;
|
||||
```
|
||||
|
||||
8. 查看MySQL初始密码:
|
||||
|
||||
```bash
|
||||
grep "password" /var/log/mysqld.log
|
||||
```
|
||||
|
||||
通过以上操作,您的MySQL可以被远程连接并进行管理。请注意在授权和更新用户权限时,应只授权特定的数据库或表格,而不是使用通配符,以提高安全性和减少不必要的权限。在进行远程访问授权时,应只授权特定的IP地址或IP地址段,而不是使用通配符,以减少潜在的安全威胁。同时,建议使用强密码,并定期更换密码以提高安全性。
|
||||
119
backend/content/posts/redis.md
Normal file
119
backend/content/posts/redis.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "Redis 安装与常用命令整理"
|
||||
slug: redis
|
||||
description: "文章介绍了 Redis 在 Debian 下的安装方法、Windows 图形客户端的安装方式,以及监听端口修改、BitMap、消息队列、LREM 和 Pipeline 等常用操作示例。"
|
||||
category: "数据库"
|
||||
post_type: "article"
|
||||
pinned: false
|
||||
published: true
|
||||
tags:
|
||||
- "Redis安装"
|
||||
- "Debian"
|
||||
- "BitMap"
|
||||
- "消息队列"
|
||||
- "Pipeline"
|
||||
- "go-redis"
|
||||
---
|
||||
|
||||
# 安装`Redis`
|
||||
|
||||
## `Debian`下安装`Redis`服务端
|
||||
|
||||
```bash
|
||||
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install redis
|
||||
```
|
||||
|
||||
## `Windows`下安装`Redis` 第三方`GUI`客户端
|
||||
|
||||
Redis (GUI)管理客户端
|
||||
|
||||
```bash
|
||||
winget install qishibo.AnotherRedisDesktopManager
|
||||
```
|
||||
|
||||
## `Redis`修改监听端口
|
||||
|
||||
```bash
|
||||
vim /etc/redis/redis.conf
|
||||
```
|
||||
|
||||
# `Redis`常用命令
|
||||
|
||||
## `bitMap`
|
||||
|
||||
使用`BitMap`实现签到,`setbit key offset value,` `key`做为时间,`offset`做为用户`id` ,`value`做为签到状态
|
||||
|
||||
```shell
|
||||
# 示例
|
||||
setbit key offset value key
|
||||
# 设置用户10086在2022/04/21进行签到
|
||||
setbit check_in_2022_04_21 10086 1
|
||||
# 获取用户10086是否在2022/04/21签到
|
||||
getbit check_in_2022_04_21 10086
|
||||
# bitcount 获取20220421签到的用户数量
|
||||
# 可选 start和end参数
|
||||
# start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位
|
||||
BITCOUNT 20220421
|
||||
# BITOP 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上
|
||||
|
||||
# operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种:
|
||||
|
||||
# BITOP AND destkey key [key ...] ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。
|
||||
|
||||
# BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。
|
||||
|
||||
# BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。
|
||||
|
||||
# BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。
|
||||
|
||||
# 除了 NOT 操作之外,其他操作都可以接受一个或多个 key 作为输入。
|
||||
|
||||
BITOP AND and-result 20220421 20220420
|
||||
GETBIT and-result
|
||||
|
||||
```
|
||||
|
||||
## `Redis` 消息队列
|
||||
|
||||
```
|
||||
# LPUSH key value, Lpush用于生产并添加消息
|
||||
# LPOP key,用于取出消息
|
||||
```
|
||||
|
||||
## `Lrem`
|
||||
|
||||
```shell
|
||||
# count > 0 : 从表头开始向表尾搜索,移除与 VALUE 相等的元素,数量为 COUNT 。
|
||||
# count < 0 : 从表尾开始向表头搜索,移除与 VALUE 相等的元素,数量为 COUNT 的绝对值。
|
||||
# count = 0 : 移除表中所有与 VALUE 相等的值。
|
||||
LREM key count VALUE
|
||||
```
|
||||
|
||||
## `Pipeline`
|
||||
|
||||
`Redis` 使用的是客户端-服务器(`CS`)模型和请求/响应协议的 TCP 服务器。这意味着通常情况下一个请求会遵循以下步骤:
|
||||
|
||||
客户端向服务端发送一个查询请求,并监听 Socket 返回,通常是以阻塞模式,等待服务端响应。
|
||||
服务端处理命令,并将结果返回给客户端。
|
||||
管道(`pipeline`)可以一次性发送多条命令并在执行完后一次性将结果返回,pipeline 通过减少客户端与 redis 的通信次数来实现降低往返延时时间,而且 `Pipeline` 实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。
|
||||
|
||||
通俗点:`pipeline`就是把一组命令进行打包,然后一次性通过网络发送到Redis。同时将执行的结果批量的返回回来
|
||||
|
||||
```go
|
||||
// 使用 go-redis
|
||||
p := Client.Pipeline()
|
||||
for _, v := range val {
|
||||
p.LRem("user:watched:"+guid, 0, v)
|
||||
}
|
||||
// p.Exec()执行pipeline 请求
|
||||
p.Exec()
|
||||
```
|
||||
|
||||
|
||||
|
||||
[本文参考](https://blog.csdn.net/mumuwang1234/article/details/118603697)
|
||||
169
backend/content/posts/rust-dll.md
Normal file
169
backend/content/posts/rust-dll.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: "手把手教你用Rust进行Dll注入"
|
||||
description: 我是一个懒惰的男孩,我甚至懒的不想按键盘上的按键和挪动鼠标.可是我还是想玩游戏,该怎么做呢?通过 google 了解到我可以通过将我自己编写的dll文件注入到目标程序内,来实现这个事情.
|
||||
date: 2022-09-17T15:10:26+08:00
|
||||
draft: false
|
||||
slug: rust-dll
|
||||
image:
|
||||
categories:
|
||||
- Rust
|
||||
tags:
|
||||
- Rust
|
||||
- Dll
|
||||
---
|
||||
|
||||
# 前言
|
||||
|
||||
我是一个懒惰的男孩,我甚至懒的不想按键盘上的按键和挪动鼠标.可是我还是想玩游戏,该怎么做呢?
|
||||
|
||||
通过google了解到我可以通过将我自己编写的 `dll` 文件注入到目标程序内,来实现这个事情.
|
||||
|
||||
将大象放在冰箱里需要几步?
|
||||
|
||||
答案是三步。
|
||||
|
||||
# `snes9x` 模拟器 `Dll` 注入实战
|
||||
|
||||
## 一、现在我们需要进行第一步,生成 `Dll` 文件
|
||||
|
||||
准确说是我们需要生成符合 `C` 标准的 `dll` 文件,如果你使用 `go` 语言,直接使用 `Cgo` 与 `C` 进行互动,即可生成符合 `C` 标准的 `dll` .
|
||||
|
||||
但是很明显,我要用 `Rust` 来做这件事。
|
||||
|
||||
由于 `Rust` 拥有出色的所有权机制,和其他语言的交互会导致 `Rust` 失去这个特性,所以这一块是属于 `Unsafe` 区域的。
|
||||
|
||||
`Rust` 默认生成的 `Dll` 是提供给 `Rust` 语言来调用的,而非C系语言的 `dll`.
|
||||
|
||||
我们现在来生成 `C` 系语言的 `Dll` 吧。
|
||||
|
||||
### 1.新建项目 `lib` 目录 `lib` 目录主要作为库文件以方便其他开发者调用
|
||||
|
||||
```bash
|
||||
# 新建库项目
|
||||
Cargo new --lib <project name>
|
||||
Cargo new --lib joy
|
||||
```
|
||||
|
||||
### 2.修改 `Cargo.toml` 文件 增加 `bin` 区域
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "joy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "joy"
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[[bin]]
|
||||
name = "joyrun"
|
||||
path = "src/main.rs"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
```
|
||||
|
||||
```bash
|
||||
# 为项目导入依赖ctor来生成符合c标准的dll
|
||||
cargo add ctor
|
||||
```
|
||||
|
||||
### 3.修改 `lib.rs` 使用 `ctor`
|
||||
|
||||
```rust
|
||||
// lib.rs
|
||||
#[ctor::ctor]
|
||||
fn ctor() {
|
||||
println!("我是一个dll")
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.编译项目生成 `joy.dll` 以及 `joyrun.exe`
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
现在我们有了我们自己的 `dll` 文件,该如何将他注入到目标的进程呢?
|
||||
|
||||
## 二、使用 `dll-syringe` 进行dll注入
|
||||
|
||||
```
|
||||
cargo add dll-syringe
|
||||
```
|
||||
|
||||
### 1.修改main.rs 将刚刚编写的dll注入到目标应用
|
||||
|
||||
```rust
|
||||
// main.rs
|
||||
use dll_syringe::{Syringe, process::OwnedProcess};
|
||||
|
||||
fn main() {
|
||||
// 通过进程名找到目标进程
|
||||
let target_process = OwnedProcess::find_first_by_name("snes9x").unwrap();
|
||||
|
||||
// 新建一个注入器
|
||||
let syringe = Syringe::for_process(target_process);
|
||||
|
||||
// 将我们刚刚编写的dll加载进去
|
||||
let injected_payload = syringe.inject("joy.dll").unwrap();
|
||||
|
||||
// do something else
|
||||
|
||||
// 将我们刚刚注入的dll从目标程序内移除
|
||||
syringe.eject(injected_payload).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.运行项目
|
||||
|
||||
```shell
|
||||
# 运行项目
|
||||
cargo run
|
||||
```
|
||||
|
||||
此时你可能会遇到一个新问题,我的`dll`已经加载进目标程序了,为什么没有打印 "我是一个dll"
|
||||
|
||||
### 3.解决控制台无输出问题
|
||||
|
||||
这是由于目标程序没有控制台,所以我们没有看到 `dll` 的输出,接下来让我们来获取 `dll` 的输出。
|
||||
|
||||
此时我们可以使用 `TCP` 交互的方式或采用 `OutputDebugStringA function (debugapi.h)` 来进行打印
|
||||
|
||||
`OutputDebugStringA` ,需要额外开启`features` `Win32_System_Diagnostics_Debug`
|
||||
|
||||
```rust
|
||||
// Rust Unsafe fn
|
||||
// windows::Win32::System::Diagnostics::Debug::OutputDebugStringA
|
||||
pub unsafe fn OutputDebugStringA<'a, P0>(lpoutputstring: P0)
|
||||
where
|
||||
P0: Into<PCSTR>,
|
||||
// Required features: "Win32_System_Diagnostics_Debug"
|
||||
```
|
||||
|
||||
采用 `Tcp` 通信交互
|
||||
|
||||
```rust
|
||||
// 在lib.rs 新建tcp客户端
|
||||
let stream = TcpStream::connect("127.0.0.1:7331").unwrap();
|
||||
```
|
||||
|
||||
```rust
|
||||
// 在main.rs 新建tcp服务端
|
||||
let (mut stream, addr) = listener.accept()?;
|
||||
info!(%addr,"Accepted!");
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let mut stdout = std::io::stdout();
|
||||
while let Ok(n) = stream.read(&mut buf[..]) {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
stdout.write_all(&buf[..n])?
|
||||
}
|
||||
```
|
||||
|
||||
```shell
|
||||
# 运行项目
|
||||
cargo run
|
||||
# 运行之后,大功告成,成功在Tcp服务端看到了,客户端对我们发起了请求。
|
||||
```
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: Rust Programming Tips
|
||||
slug: rust-programming-tips
|
||||
description: Essential tips for Rust developers including ownership, pattern matching, and error handling.
|
||||
category: tech
|
||||
post_type: article
|
||||
pinned: false
|
||||
published: true
|
||||
tags:
|
||||
- rust
|
||||
- programming
|
||||
- tips
|
||||
---
|
||||
|
||||
# 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!
|
||||
96
backend/content/posts/rust-serde.md
Normal file
96
backend/content/posts/rust-serde.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: "Rust使用Serde进行序列化及反序列化"
|
||||
description: 这篇文章将介绍如何在Rust编程语言中使用Serde库进行序列化和反序列化操作。Serde是一个广泛使用的序列化和反序列化库,能够支持JSON、BSON、CBOR、MessagePack和YAML等常见数据格式。
|
||||
date: 2022-07-25T14:02:22+08:00
|
||||
draft: false
|
||||
slug: rust-serde
|
||||
image:
|
||||
categories:
|
||||
- Rust
|
||||
tags:
|
||||
- Rust
|
||||
- Xml
|
||||
---
|
||||
|
||||
# 开始之前
|
||||
|
||||
```toml
|
||||
# 在Cargo.toml 新增以下依赖
|
||||
[dependencies]
|
||||
serde = { version = "1.0.140",features = ["derive"] }
|
||||
serde_json = "1.0.82"
|
||||
serde_yaml = "0.8"
|
||||
serde_urlencoded = "0.7.1"
|
||||
# 使用yaserde解析xml
|
||||
yaserde = "0.8.0"
|
||||
yaserde_derive = "0.8.0"
|
||||
```
|
||||
|
||||
## `Serde`通用规则(`json`,`yaml`,`xml`)
|
||||
|
||||
### 1.使用`Serde`宏通过具体结构实现序列化及反序列化
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
// 为结构体实现 Serialize(序列化)属性和Deserialize(反序列化)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Person {
|
||||
// 将该字段名称修改为lastname
|
||||
#[serde(rename = "lastname")]
|
||||
name: String,
|
||||
// 反序列化及序列化时忽略该字段(nickname)
|
||||
#[serde(skip)]
|
||||
nickname: String,
|
||||
// 分别设置序列化及反序列化时输出的字段名称
|
||||
#[serde(rename(serialize = "serialize_id", deserialize = "derialize_id"))
|
||||
id: i32,
|
||||
// 为age设置默认值
|
||||
#[serde(default)]
|
||||
age: i32,
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### 2.使用`serde_json`序列化及反序列化
|
||||
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
let v:serde_json::Value = json!(
|
||||
{
|
||||
"x":20.0,
|
||||
"y":15.0
|
||||
}
|
||||
);
|
||||
println!("x:{:#?},y:{:#?}",v["x"],v["y"]); // x:20.0, y:15.0
|
||||
```
|
||||
|
||||
### 3.使用`Serde`宏统一格式化输入、输出字段名称
|
||||
|
||||
| 方法名 | 方法效果 |
|
||||
| ------------------------------- | ------------------------------------------------------------ |
|
||||
| `PascalCase` | 首字母为大写的驼峰式命名,推荐结构体、枚举等名称以及`Yaml`配置文件读取使用。 |
|
||||
| `camelCase` | 首字母为小写的驼峰式命名,推荐`Yaml`配置文件读取使用。 |
|
||||
| `snake_case` | 小蛇形命名,用下划线"`_`"连接单词,推荐函数命名以及变量名称使用此种方式。 |
|
||||
| `SCREAMING_SNAKE_CASE` | 大蛇形命名,单词均为大写形式,用下划线"`_`"连接单词。推荐常数及全局变量使用此种方式。 |
|
||||
| `kebab-case`(小串烤肉) | 同`snake_case`,使用中横线"`-`"替换了下划线"`_`"。 |
|
||||
| `SCREAMING-KEBAB-CAS`(大串烤肉) | 同`SCREAMING_SNAKE_CASE`,使用中横线"`-`"替换了下划线"`_`"。 |
|
||||
|
||||
示例:
|
||||
|
||||
```rust
|
||||
pub struct App {
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
/// 统一格式化输入、输出字段名称
|
||||
/// #[serde(rename_all = "camelCase")]
|
||||
/// #[serde(rename_all = "snake_case")]
|
||||
/// #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
/// 仅设置
|
||||
version: String,
|
||||
app_name: String,
|
||||
host: String,
|
||||
}
|
||||
```
|
||||
|
||||
[本文参考:yaserde](https://github.com/media-io/yaserde)
|
||||
|
||||
[本文参考:magiclen](https://magiclen.org/rust-serde/)
|
||||
37
backend/content/posts/rust-sqlx.md
Normal file
37
backend/content/posts/rust-sqlx.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Rust Sqlx"
|
||||
description:
|
||||
date: 2022-08-29T13:55:08+08:00
|
||||
draft: true
|
||||
slug: rust-sqlx
|
||||
image:
|
||||
categories:
|
||||
-
|
||||
tags:
|
||||
-
|
||||
---
|
||||
|
||||
# sqlx-cli
|
||||
|
||||
## 创建 migration
|
||||
|
||||
```shell
|
||||
sqlx migrate add categories
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Add migration script here
|
||||
CREATE TABLE IF NOT EXISTS categories(
|
||||
id INT PRIMARY KEY DEFAULT AUTO_INCREMENT,
|
||||
type_id INT UNIQUE NOT NULL,
|
||||
parent_id INT NOT NULL,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
);
|
||||
```
|
||||
|
||||
## 运行 migration
|
||||
|
||||
```sh
|
||||
sqlx migrate run
|
||||
```
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: Terminal UI Design Principles
|
||||
slug: terminal-ui-design
|
||||
description: Learn the key principles of designing beautiful terminal-style user interfaces.
|
||||
category: design
|
||||
post_type: article
|
||||
pinned: false
|
||||
published: true
|
||||
tags:
|
||||
- design
|
||||
- terminal
|
||||
- ui
|
||||
---
|
||||
|
||||
# Terminal UI Design Principles
|
||||
|
||||
Terminal-style interfaces are making a comeback in modern web design.
|
||||
|
||||
## Key Elements
|
||||
|
||||
1. Monospace fonts
|
||||
2. Dark themes
|
||||
3. Command prompts
|
||||
4. ASCII art
|
||||
5. Blinking 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.
|
||||
54
backend/content/posts/tmux.md
Normal file
54
backend/content/posts/tmux.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "在 Tmux 会话窗格中发送命令的方法"
|
||||
slug: tmux
|
||||
description: "介绍如何在 Tmux 中创建分离会话、向指定窗格发送命令并执行回车,同时说明连接会话和发送特殊按键的基本用法。"
|
||||
category: "Linux"
|
||||
post_type: "article"
|
||||
pinned: false
|
||||
published: true
|
||||
tags:
|
||||
- "Tmux"
|
||||
- "终端复用"
|
||||
- "send-keys"
|
||||
- "会话管理"
|
||||
- "命令行"
|
||||
---
|
||||
|
||||
## 在 Tmux 会话窗格中发送命令的方法
|
||||
|
||||
在 `Tmux` 中,可以使用 `send-keys` 命令将命令发送到会话窗格中。以下是在 `Tmux` 中发送命令的步骤:
|
||||
|
||||
### 1. 新建一个分离(`Detached`)会话
|
||||
|
||||
使用以下命令新建一个分离会话:
|
||||
|
||||
```bash
|
||||
tmux new -d -s mySession
|
||||
```
|
||||
|
||||
### 2. 发送命令至会话窗格
|
||||
|
||||
使用以下命令将命令发送到会话窗格:
|
||||
|
||||
```bash
|
||||
tmux send-keys -t mySession "echo 'Hello World!'" ENTER
|
||||
```
|
||||
|
||||
这将发送 `echo 'Hello World!'` 命令,并模拟按下回车键(`ENTER`),以在会话窗格中执行该命令。
|
||||
|
||||
### 3. 连接(`Attach`)会话窗格
|
||||
|
||||
使用以下命令连接会话窗格:
|
||||
|
||||
```bash
|
||||
tmux a -t mySession
|
||||
```
|
||||
|
||||
这将连接到名为 `mySession` 的会话窗格。
|
||||
|
||||
### 4. 发送特殊命令
|
||||
|
||||
要发送特殊命令,例如清除当前行或使用管理员权限运行命令,请使用以下命令:
|
||||
|
||||
- 清除当前行:`tmux send-keys C-c`
|
||||
- 以管理员身份运行命令:`sudo tmux send-keys ...`
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
title: Welcome to Termi Blog
|
||||
slug: welcome-to-termi
|
||||
description: Welcome to our new blog built with Astro and Loco.rs backend.
|
||||
category: general
|
||||
post_type: article
|
||||
pinned: true
|
||||
published: true
|
||||
tags:
|
||||
- welcome
|
||||
- astro
|
||||
- loco-rs
|
||||
---
|
||||
|
||||
# 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!
|
||||
9
backend/docker-entrypoint.sh
Normal file
9
backend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${TERMI_SKIP_MIGRATIONS:-false}" != "true" ]; then
|
||||
echo "[entrypoint] running database migrations..."
|
||||
termi_api-cli -e production db migrate
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -17,6 +17,25 @@ mod m20260328_000006_add_ai_to_site_settings;
|
||||
mod m20260328_000007_create_ai_chunks;
|
||||
mod m20260328_000008_enable_pgvector_for_ai_chunks;
|
||||
mod m20260328_000009_add_paragraph_comments;
|
||||
mod m20260328_000010_add_paragraph_comments_toggle_to_site_settings;
|
||||
mod m20260328_000011_add_post_images_and_music_playlist;
|
||||
mod m20260329_000012_add_link_url_to_reviews;
|
||||
mod m20260329_000013_add_ai_provider_presets_to_site_settings;
|
||||
mod m20260329_000014_create_query_events;
|
||||
mod m20260330_000015_add_image_ai_settings_to_site_settings;
|
||||
mod m20260330_000016_add_r2_media_settings_to_site_settings;
|
||||
mod m20260330_000017_add_media_storage_provider_to_site_settings;
|
||||
mod m20260331_000018_add_comment_request_metadata;
|
||||
mod m20260331_000019_create_comment_blacklist;
|
||||
mod m20260331_000020_create_comment_persona_analysis_logs;
|
||||
mod m20260331_000021_add_post_lifecycle_and_seo;
|
||||
mod m20260331_000022_add_site_settings_notifications_and_seo;
|
||||
mod m20260331_000023_create_content_events;
|
||||
mod m20260331_000024_create_admin_audit_logs;
|
||||
mod m20260331_000025_create_post_revisions;
|
||||
mod m20260331_000026_create_subscriptions;
|
||||
mod m20260331_000027_create_notification_deliveries;
|
||||
mod m20260331_000028_expand_subscriptions_and_deliveries;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -38,6 +57,25 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260328_000007_create_ai_chunks::Migration),
|
||||
Box::new(m20260328_000008_enable_pgvector_for_ai_chunks::Migration),
|
||||
Box::new(m20260328_000009_add_paragraph_comments::Migration),
|
||||
Box::new(m20260328_000010_add_paragraph_comments_toggle_to_site_settings::Migration),
|
||||
Box::new(m20260328_000011_add_post_images_and_music_playlist::Migration),
|
||||
Box::new(m20260329_000012_add_link_url_to_reviews::Migration),
|
||||
Box::new(m20260329_000013_add_ai_provider_presets_to_site_settings::Migration),
|
||||
Box::new(m20260329_000014_create_query_events::Migration),
|
||||
Box::new(m20260330_000015_add_image_ai_settings_to_site_settings::Migration),
|
||||
Box::new(m20260330_000016_add_r2_media_settings_to_site_settings::Migration),
|
||||
Box::new(m20260330_000017_add_media_storage_provider_to_site_settings::Migration),
|
||||
Box::new(m20260331_000018_add_comment_request_metadata::Migration),
|
||||
Box::new(m20260331_000019_create_comment_blacklist::Migration),
|
||||
Box::new(m20260331_000020_create_comment_persona_analysis_logs::Migration),
|
||||
Box::new(m20260331_000021_add_post_lifecycle_and_seo::Migration),
|
||||
Box::new(m20260331_000022_add_site_settings_notifications_and_seo::Migration),
|
||||
Box::new(m20260331_000023_create_content_events::Migration),
|
||||
Box::new(m20260331_000024_create_admin_audit_logs::Migration),
|
||||
Box::new(m20260331_000025_create_post_revisions::Migration),
|
||||
Box::new(m20260331_000026_create_subscriptions::Migration),
|
||||
Box::new(m20260331_000027_create_notification_deliveries::Migration),
|
||||
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ impl MigrationTrait for Migration {
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(ColumnDef::new(Alias::new("paragraph_excerpt")).string().null())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("paragraph_excerpt"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
if !manager
|
||||
.has_column("site_settings", "paragraph_comments_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("site_settings"))
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("paragraph_comments_enabled"))
|
||||
.boolean()
|
||||
.null()
|
||||
.default(true),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
if manager
|
||||
.has_column("site_settings", "paragraph_comments_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("site_settings"))
|
||||
.drop_column(Alias::new("paragraph_comments_enabled"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let posts_table = Alias::new("posts");
|
||||
let site_settings_table = Alias::new("site_settings");
|
||||
|
||||
if !manager.has_column("posts", "images").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(posts_table.clone())
|
||||
.add_column(ColumnDef::new(Alias::new("images")).json_binary().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "music_playlist")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(site_settings_table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("music_playlist"))
|
||||
.json_binary()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let posts_table = Alias::new("posts");
|
||||
let site_settings_table = Alias::new("site_settings");
|
||||
|
||||
if manager.has_column("posts", "images").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(posts_table)
|
||||
.drop_column(Alias::new("images"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if manager
|
||||
.has_column("site_settings", "music_playlist")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(site_settings_table)
|
||||
.drop_column(Alias::new("music_playlist"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user