Compare commits
1 Commits
a9a05aa105
...
checkpoint
| Author | SHA1 | Date | |
|---|---|---|---|
| 313f174fbc |
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
|
||||
|
||||
73
README.md
73
README.md
@@ -8,8 +8,9 @@ Monorepo for the Termi blog system.
|
||||
.
|
||||
├─ admin/ # React + shadcn admin workspace
|
||||
├─ frontend/ # Astro blog frontend
|
||||
├─ backend/ # Loco.rs backend APIs and legacy Tera admin
|
||||
├─ backend/ # Loco.rs backend APIs
|
||||
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
|
||||
├─ deploy/ # Deployment manifests (docker compose/env examples)
|
||||
├─ .codex/ # Codex workspace config
|
||||
└─ .vscode/ # Editor workspace config
|
||||
```
|
||||
@@ -70,16 +71,16 @@ Legacy aliases are still available and now just forward to `dev.ps1`:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Admin
|
||||
|
||||
```powershell
|
||||
cd admin
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Backend
|
||||
@@ -90,6 +91,68 @@ $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
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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__ || {}
|
||||
@@ -38,6 +38,10 @@ 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 }
|
||||
@@ -58,6 +62,14 @@ 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
|
||||
@@ -140,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)
|
||||
@@ -167,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)
|
||||
@@ -233,6 +251,14 @@ function AppRoutes() {
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="revisions"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<RevisionsPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="comments"
|
||||
element={
|
||||
@@ -257,6 +283,22 @@ function AppRoutes() {
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="subscriptions"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SubscriptionsPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="audit"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<AuditPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="reviews"
|
||||
element={
|
||||
@@ -283,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)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
BarChart3,
|
||||
BellRing,
|
||||
BookOpenText,
|
||||
ExternalLink,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
@@ -18,6 +20,7 @@ import { NavLink } from 'react-router-dom'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const primaryNav = [
|
||||
@@ -39,6 +42,12 @@ const primaryNav = [
|
||||
description: 'Markdown 内容管理',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
to: '/revisions',
|
||||
label: '版本',
|
||||
description: '历史快照与一键回滚',
|
||||
icon: History,
|
||||
},
|
||||
{
|
||||
to: '/comments',
|
||||
label: '评论',
|
||||
@@ -63,6 +72,18 @@ const primaryNav = [
|
||||
description: '对象存储图片管理',
|
||||
icon: ImageIcon,
|
||||
},
|
||||
{
|
||||
to: '/subscriptions',
|
||||
label: '订阅',
|
||||
description: '邮件 / Webhook 推送',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
to: '/audit',
|
||||
label: '审计',
|
||||
description: '后台操作审计日志',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
label: '设置',
|
||||
@@ -74,12 +95,20 @@ const primaryNav = [
|
||||
export function AppShell({
|
||||
children,
|
||||
username,
|
||||
email,
|
||||
authSource,
|
||||
authProvider,
|
||||
loggingOut,
|
||||
canLogout,
|
||||
onLogout,
|
||||
}: {
|
||||
children: ReactNode
|
||||
username: string | null
|
||||
email: string | null
|
||||
authSource: string | null
|
||||
authProvider: string | null
|
||||
loggingOut: boolean
|
||||
canLogout: boolean
|
||||
onLogout: () => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
@@ -155,7 +184,7 @@ export function AppShell({
|
||||
工作台状态
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
核心后台流程已经迁移到独立管理端。
|
||||
核心后台流程统一运行在当前独立管理端。
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success">运行中</Badge>
|
||||
@@ -186,8 +215,13 @@ export function AppShell({
|
||||
当前登录:{username ?? 'admin'}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
React + shadcn/ui 基础架构
|
||||
{authProvider ?? 'React + shadcn/ui 基础架构'}
|
||||
</p>
|
||||
{email ? (
|
||||
<p className="text-xs text-muted-foreground">{email}</p>
|
||||
) : authSource ? (
|
||||
<p className="text-xs text-muted-foreground">认证来源:{authSource}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,14 +247,19 @@ export function AppShell({
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl('/')} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开前台
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void onLogout()} disabled={loggingOut}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void onLogout()}
|
||||
disabled={loggingOut || !canLogout}
|
||||
title={canLogout ? undefined : '当前会话由前置 SSO / 代理控制'}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{loggingOut ? '退出中...' : '退出登录'}
|
||||
{canLogout ? (loggingOut ? '退出中...' : '退出登录') : 'SSO 受代理保护'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -342,7 +342,7 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
}, [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/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]',
|
||||
'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,
|
||||
)
|
||||
|
||||
@@ -352,7 +352,7 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
ref={menuRef}
|
||||
aria-orientation="vertical"
|
||||
className={cn(
|
||||
'custom-select-popover fixed z-[80] overflow-hidden rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_48px_rgb(15_23_42_/_0.18)] will-change-transform',
|
||||
'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}
|
||||
@@ -374,13 +374,13 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
}}
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
|
||||
'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
|
||||
? 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)]'
|
||||
? 'border-primary/15 bg-primary/[0.045] text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.55)]'
|
||||
: highlighted
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
? '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)}
|
||||
@@ -392,8 +392,31 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
<Check className={cn('h-4 w-4 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
@@ -459,7 +482,12 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors">
|
||||
<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>
|
||||
|
||||
@@ -41,6 +41,76 @@ export function formatCommentScope(value: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPostStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'draft':
|
||||
return '草稿'
|
||||
case 'published':
|
||||
return '已发布'
|
||||
case 'scheduled':
|
||||
return '定时发布'
|
||||
case 'expired':
|
||||
return '已下线'
|
||||
case 'offline':
|
||||
return '离线'
|
||||
default:
|
||||
return value || '已发布'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPostVisibility(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'unlisted':
|
||||
return '不公开'
|
||||
case 'private':
|
||||
return '私有'
|
||||
case 'public':
|
||||
return '公开'
|
||||
default:
|
||||
return value || '公开'
|
||||
}
|
||||
}
|
||||
|
||||
function matchBrowserVersion(userAgent: string, marker: RegExp) {
|
||||
const matched = userAgent.match(marker)
|
||||
return matched?.[1] ?? null
|
||||
}
|
||||
|
||||
export function formatBrowserName(userAgent: string | null | undefined) {
|
||||
if (!userAgent) {
|
||||
return '未知浏览器'
|
||||
}
|
||||
|
||||
const ua = userAgent.toLowerCase()
|
||||
|
||||
if (ua.includes('edg/')) {
|
||||
const version = matchBrowserVersion(userAgent, /edg\/([\d.]+)/i)
|
||||
return version ? `Edge ${version}` : 'Edge'
|
||||
}
|
||||
|
||||
if (ua.includes('opr/') || ua.includes('opera')) {
|
||||
const version = matchBrowserVersion(userAgent, /(?:opr|opera)\/([\d.]+)/i)
|
||||
return version ? `Opera ${version}` : 'Opera'
|
||||
}
|
||||
|
||||
if (ua.includes('firefox/')) {
|
||||
const version = matchBrowserVersion(userAgent, /firefox\/([\d.]+)/i)
|
||||
return version ? `Firefox ${version}` : 'Firefox'
|
||||
}
|
||||
|
||||
if (ua.includes('chrome/') && !ua.includes('chromium/')) {
|
||||
const version = matchBrowserVersion(userAgent, /chrome\/([\d.]+)/i)
|
||||
return version ? `Chrome ${version}` : 'Chrome'
|
||||
}
|
||||
|
||||
if (ua.includes('safari/') && !ua.includes('chrome/')) {
|
||||
const version = matchBrowserVersion(userAgent, /version\/([\d.]+)/i)
|
||||
return version ? `Safari ${version}` : 'Safari'
|
||||
}
|
||||
|
||||
return '其他浏览器'
|
||||
}
|
||||
|
||||
export function formatFriendLinkStatus(value: string | null | undefined) {
|
||||
switch (value) {
|
||||
case 'approved':
|
||||
|
||||
@@ -4,8 +4,11 @@ import type {
|
||||
AdminAiReindexResponse,
|
||||
AdminAiProviderTestResponse,
|
||||
AdminImageUploadResponse,
|
||||
AdminMediaBatchDeleteResponse,
|
||||
AdminMediaDeleteResponse,
|
||||
AdminMediaListResponse,
|
||||
AdminMediaReplaceResponse,
|
||||
AdminMediaUploadResponse,
|
||||
AdminPostCoverImageRequest,
|
||||
AdminPostCoverImageResponse,
|
||||
AdminDashboardResponse,
|
||||
@@ -16,7 +19,11 @@ import type {
|
||||
AdminR2ConnectivityResponse,
|
||||
AdminSessionResponse,
|
||||
AdminSiteSettingsResponse,
|
||||
AuditLogRecord,
|
||||
CommentListQuery,
|
||||
CommentBlacklistRecord,
|
||||
CommentPersonaAnalysisLogRecord,
|
||||
CommentPersonaAnalysisResponse,
|
||||
CommentRecord,
|
||||
CreatePostPayload,
|
||||
CreateReviewPayload,
|
||||
@@ -26,16 +33,52 @@ import type {
|
||||
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
|
||||
@@ -95,6 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
})
|
||||
|
||||
@@ -126,6 +170,74 @@ 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'),
|
||||
@@ -192,6 +304,35 @@ export const adminApi = {
|
||||
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',
|
||||
@@ -237,9 +378,16 @@ export const adminApi = {
|
||||
search: query?.search,
|
||||
type: query?.postType,
|
||||
pinned: query?.pinned,
|
||||
status: query?.status,
|
||||
visibility: query?.visibility,
|
||||
listed_only: query?.listedOnly,
|
||||
include_private: query?.includePrivate ?? true,
|
||||
include_redirects: query?.includeRedirects ?? true,
|
||||
preview: query?.preview ?? true,
|
||||
}),
|
||||
),
|
||||
getPostBySlug: (slug: string) => request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}`),
|
||||
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',
|
||||
@@ -254,6 +402,15 @@ export const adminApi = {
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
status: payload.status,
|
||||
visibility: payload.visibility,
|
||||
publish_at: payload.publishAt,
|
||||
unpublish_at: payload.unpublishAt,
|
||||
canonical_url: payload.canonicalUrl,
|
||||
noindex: payload.noindex,
|
||||
og_image: payload.ogImage,
|
||||
redirect_from: payload.redirectFrom,
|
||||
redirect_to: payload.redirectTo,
|
||||
published: payload.published,
|
||||
}),
|
||||
}),
|
||||
@@ -271,6 +428,15 @@ export const adminApi = {
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
status: payload.status,
|
||||
visibility: payload.visibility,
|
||||
publish_at: payload.publishAt,
|
||||
unpublish_at: payload.unpublishAt,
|
||||
canonical_url: payload.canonicalUrl,
|
||||
noindex: payload.noindex,
|
||||
og_image: payload.ogImage,
|
||||
redirect_from: payload.redirectFrom,
|
||||
redirect_to: payload.redirectTo,
|
||||
}),
|
||||
}),
|
||||
getPostMarkdown: (slug: string) =>
|
||||
@@ -315,6 +481,59 @@ export const adminApi = {
|
||||
request<void>(`/api/comments/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listCommentBlacklist: () =>
|
||||
request<CommentBlacklistRecord[]>('/api/admin/comments/blacklist'),
|
||||
createCommentBlacklist: (payload: {
|
||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value: string
|
||||
reason?: string | null
|
||||
active?: boolean
|
||||
expires_at?: string | null
|
||||
}) =>
|
||||
request<CommentBlacklistRecord>('/api/admin/comments/blacklist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateCommentBlacklist: (
|
||||
id: number,
|
||||
payload: {
|
||||
reason?: string | null
|
||||
active?: boolean
|
||||
expires_at?: string | null
|
||||
clear_expires_at?: boolean
|
||||
},
|
||||
) =>
|
||||
request<CommentBlacklistRecord>(`/api/admin/comments/blacklist/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteCommentBlacklist: (id: number) =>
|
||||
request<{ deleted: boolean; id: number }>(`/api/admin/comments/blacklist/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
analyzeCommentPersona: (payload: {
|
||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value: string
|
||||
from?: string | null
|
||||
to?: string | null
|
||||
limit?: number
|
||||
}) =>
|
||||
request<CommentPersonaAnalysisResponse>('/api/admin/comments/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listCommentPersonaAnalysisLogs: (query?: {
|
||||
matcher_type?: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value?: string
|
||||
limit?: number
|
||||
}) =>
|
||||
request<CommentPersonaAnalysisLogRecord[]>(
|
||||
appendQueryParams('/api/admin/comments/analyze/logs', {
|
||||
matcher_type: query?.matcher_type,
|
||||
matcher_value: query?.matcher_value,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
listFriendLinks: (query?: FriendLinkListQuery) =>
|
||||
request<FriendLinkRecord[]>(
|
||||
appendQueryParams('/api/friend_links', {
|
||||
|
||||
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)}%`
|
||||
}
|
||||
@@ -9,7 +9,15 @@ export type ParsedMarkdownMeta = {
|
||||
image: string
|
||||
images: string[]
|
||||
pinned: boolean
|
||||
published: boolean
|
||||
status: string
|
||||
visibility: string
|
||||
publishAt: string
|
||||
unpublishAt: string
|
||||
canonicalUrl: string
|
||||
noindex: boolean
|
||||
ogImage: string
|
||||
redirectFrom: string[]
|
||||
redirectTo: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
@@ -28,7 +36,15 @@ const defaultMeta: ParsedMarkdownMeta = {
|
||||
image: '',
|
||||
images: [],
|
||||
pinned: false,
|
||||
published: true,
|
||||
status: 'published',
|
||||
visibility: 'public',
|
||||
publishAt: '',
|
||||
unpublishAt: '',
|
||||
canonicalUrl: '',
|
||||
noindex: false,
|
||||
ogImage: '',
|
||||
redirectFrom: [],
|
||||
redirectTo: '',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
@@ -102,7 +118,7 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
|
||||
|
||||
const frontmatter = normalized.slice(4, endIndex)
|
||||
const body = normalized.slice(endIndex + 5).trimStart()
|
||||
let currentListKey: 'tags' | 'images' | 'categories' | null = null
|
||||
let currentListKey: 'tags' | 'images' | 'categories' | 'redirect_from' | null = null
|
||||
const categories: string[] = []
|
||||
|
||||
frontmatter.split('\n').forEach((line) => {
|
||||
@@ -118,6 +134,8 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
|
||||
meta.tags.push(nextValue)
|
||||
} else if (currentListKey === 'images') {
|
||||
meta.images.push(nextValue)
|
||||
} else if (currentListKey === 'redirect_from') {
|
||||
meta.redirectFrom.push(nextValue)
|
||||
} else {
|
||||
categories.push(nextValue)
|
||||
}
|
||||
@@ -155,6 +173,16 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'redirect_from') {
|
||||
const redirectFrom = toStringList(value)
|
||||
if (redirectFrom.length) {
|
||||
meta.redirectFrom = redirectFrom
|
||||
} else if (!String(rawValue).trim()) {
|
||||
currentListKey = 'redirect_from'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'categories' || key === 'category') {
|
||||
const parsedCategories = toStringList(value)
|
||||
if (parsedCategories.length) {
|
||||
@@ -184,12 +212,36 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
|
||||
case 'pinned':
|
||||
meta.pinned = Boolean(value)
|
||||
break
|
||||
case 'status':
|
||||
meta.status = String(value).trim() || 'published'
|
||||
break
|
||||
case 'visibility':
|
||||
meta.visibility = String(value).trim() || 'public'
|
||||
break
|
||||
case 'publish_at':
|
||||
meta.publishAt = String(value).trim()
|
||||
break
|
||||
case 'unpublish_at':
|
||||
meta.unpublishAt = String(value).trim()
|
||||
break
|
||||
case 'canonical_url':
|
||||
meta.canonicalUrl = String(value).trim()
|
||||
break
|
||||
case 'noindex':
|
||||
meta.noindex = Boolean(value)
|
||||
break
|
||||
case 'og_image':
|
||||
meta.ogImage = String(value).trim()
|
||||
break
|
||||
case 'redirect_to':
|
||||
meta.redirectTo = String(value).trim()
|
||||
break
|
||||
case 'published':
|
||||
meta.published = value !== false
|
||||
meta.status = value === false ? 'draft' : 'published'
|
||||
break
|
||||
case 'draft':
|
||||
if (value === true) {
|
||||
meta.published = false
|
||||
meta.status = 'draft'
|
||||
}
|
||||
break
|
||||
default:
|
||||
@@ -223,7 +275,17 @@ export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
|
||||
|
||||
lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`)
|
||||
lines.push(`pinned: ${meta.pinned ? 'true' : 'false'}`)
|
||||
lines.push(`published: ${meta.published ? 'true' : 'false'}`)
|
||||
lines.push(`status: ${JSON.stringify(meta.status.trim() || 'published')}`)
|
||||
lines.push(`visibility: ${JSON.stringify(meta.visibility.trim() || 'public')}`)
|
||||
lines.push(`noindex: ${meta.noindex ? 'true' : 'false'}`)
|
||||
|
||||
if (meta.publishAt.trim()) {
|
||||
lines.push(`publish_at: ${JSON.stringify(meta.publishAt.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.unpublishAt.trim()) {
|
||||
lines.push(`unpublish_at: ${JSON.stringify(meta.unpublishAt.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.image.trim()) {
|
||||
lines.push(`image: ${JSON.stringify(meta.image.trim())}`)
|
||||
@@ -243,5 +305,24 @@ export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.canonicalUrl.trim()) {
|
||||
lines.push(`canonical_url: ${JSON.stringify(meta.canonicalUrl.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.ogImage.trim()) {
|
||||
lines.push(`og_image: ${JSON.stringify(meta.ogImage.trim())}`)
|
||||
}
|
||||
|
||||
if (meta.redirectFrom.length) {
|
||||
lines.push('redirect_from:')
|
||||
meta.redirectFrom.forEach((item) => {
|
||||
lines.push(` - ${JSON.stringify(item)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (meta.redirectTo.trim()) {
|
||||
lines.push(`redirect_to: ${JSON.stringify(meta.redirectTo.trim())}`)
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n---\n\n${body.trim()}\n`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -84,6 +214,16 @@ export interface AnalyticsOverview {
|
||||
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
|
||||
@@ -108,6 +248,20 @@ export interface AnalyticsProviderBucket {
|
||||
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
|
||||
@@ -116,10 +270,13 @@ export interface AnalyticsDailyBucket {
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -167,6 +324,12 @@ export interface AdminSiteSettingsResponse {
|
||||
media_r2_public_base_url: string | null
|
||||
media_r2_access_key_id: string | null
|
||||
media_r2_secret_access_key: string | null
|
||||
seo_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 {
|
||||
@@ -219,6 +382,12 @@ export interface SiteSettingsPayload {
|
||||
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 {
|
||||
@@ -269,6 +438,74 @@ export interface AdminMediaDeleteResponse {
|
||||
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
|
||||
@@ -334,6 +571,15 @@ export interface PostRecord {
|
||||
image: string | null
|
||||
images: string[] | null
|
||||
pinned: boolean | null
|
||||
status: string | null
|
||||
visibility: string | null
|
||||
publish_at: string | null
|
||||
unpublish_at: string | null
|
||||
canonical_url: string | null
|
||||
noindex: boolean | null
|
||||
og_image: string | null
|
||||
redirect_from: string[] | null
|
||||
redirect_to: string | null
|
||||
}
|
||||
|
||||
export interface PostListQuery {
|
||||
@@ -343,6 +589,12 @@ export interface PostListQuery {
|
||||
search?: string
|
||||
postType?: string
|
||||
pinned?: boolean
|
||||
status?: string
|
||||
visibility?: string
|
||||
listedOnly?: boolean
|
||||
includePrivate?: boolean
|
||||
includeRedirects?: boolean
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
export interface CreatePostPayload {
|
||||
@@ -356,6 +608,15 @@ export interface CreatePostPayload {
|
||||
image?: string | null
|
||||
images?: string[] | null
|
||||
pinned?: boolean
|
||||
status?: string | null
|
||||
visibility?: string | null
|
||||
publishAt?: string | null
|
||||
unpublishAt?: string | null
|
||||
canonicalUrl?: string | null
|
||||
noindex?: boolean
|
||||
ogImage?: string | null
|
||||
redirectFrom?: string[]
|
||||
redirectTo?: string | null
|
||||
published?: boolean
|
||||
}
|
||||
|
||||
@@ -370,6 +631,15 @@ export interface UpdatePostPayload {
|
||||
image?: string | null
|
||||
images?: string[] | null
|
||||
pinned?: boolean | null
|
||||
status?: string | null
|
||||
visibility?: string | null
|
||||
publishAt?: string | null
|
||||
unpublishAt?: string | null
|
||||
canonicalUrl?: string | null
|
||||
noindex?: boolean | null
|
||||
ogImage?: string | null
|
||||
redirectFrom?: string[]
|
||||
redirectTo?: string | null
|
||||
}
|
||||
|
||||
export interface MarkdownDocumentResponse {
|
||||
@@ -397,6 +667,9 @@ export interface CommentRecord {
|
||||
author: string | null
|
||||
email: string | null
|
||||
avatar: string | null
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
referer: string | null
|
||||
content: string | null
|
||||
scope: string
|
||||
paragraph_key: string | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
|
||||
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
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({
|
||||
@@ -56,6 +57,29 @@ function formatSuccess(value: boolean | null) {
|
||||
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)
|
||||
@@ -147,22 +171,49 @@ export function AnalyticsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
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>
|
||||
<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 提问,方便你判断内容需求、热点问题和接入质量。
|
||||
这里会同时记录站内搜索、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">
|
||||
<BrainCircuit className="h-4 w-4" />
|
||||
打开问答页
|
||||
</a>
|
||||
@@ -184,6 +235,12 @@ export function AnalyticsPage() {
|
||||
))}
|
||||
</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>
|
||||
@@ -246,11 +303,69 @@ export function AnalyticsPage() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
基于 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>
|
||||
@@ -319,35 +434,70 @@ export function AnalyticsPage() {
|
||||
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>分析侧栏</CardTitle>
|
||||
<CardTitle>内容消费概览</CardTitle>
|
||||
<CardDescription>
|
||||
24 小时、7 天和模型渠道维度的快速摘要。
|
||||
浏览量、完读率和阅读时长的快速摘要。
|
||||
</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.overview.searches_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">
|
||||
24 小时 AI 提问
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_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">
|
||||
AI 平均耗时
|
||||
24 小时页面访问
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{data.overview.avg_ai_latency_ms_last_7d !== null
|
||||
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
|
||||
: '暂无'}
|
||||
{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>
|
||||
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BrainCircuit,
|
||||
Clock3,
|
||||
FolderTree,
|
||||
MessageSquareWarning,
|
||||
RefreshCcw,
|
||||
@@ -24,10 +25,13 @@ 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'
|
||||
@@ -120,6 +124,16 @@ export function DashboardPage() {
|
||||
note: '等待审核处理',
|
||||
icon: MessageSquareWarning,
|
||||
},
|
||||
{
|
||||
label: '发布待办',
|
||||
value:
|
||||
data.stats.draft_posts +
|
||||
data.stats.scheduled_posts +
|
||||
data.stats.offline_posts +
|
||||
data.stats.expired_posts,
|
||||
note: `草稿 ${data.stats.draft_posts} / 定时 ${data.stats.scheduled_posts} / 下线 ${data.stats.offline_posts + data.stats.expired_posts}`,
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: '分类数量',
|
||||
value: data.stats.total_categories,
|
||||
@@ -149,7 +163,7 @@ export function DashboardPage() {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
打开 AI 问答
|
||||
</a>
|
||||
@@ -188,6 +202,7 @@ export function DashboardPage() {
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
@@ -207,6 +222,12 @@ export function DashboardPage() {
|
||||
<TableCell className="uppercase text-muted-foreground">
|
||||
{formatPostType(post.post_type)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
|
||||
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{post.category}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
|
||||
</TableRow>
|
||||
@@ -257,6 +278,34 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
发布队列
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
|
||||
<p className="text-xs text-muted-foreground">草稿</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
|
||||
<p className="text-xs text-muted-foreground">定时发布</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
|
||||
<p className="text-xs text-muted-foreground">手动下线</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
|
||||
<p className="text-xs text-muted-foreground">自动过期</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline">私有 {data.stats.private_posts}</Badge>
|
||||
<Badge variant="outline">不公开 {data.stats.unlisted_posts}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
最近一次 AI 索引
|
||||
@@ -275,7 +324,7 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<CardTitle>待审核评论</CardTitle>
|
||||
<CardDescription>
|
||||
不进入旧后台也能查看审核队列。
|
||||
在当前管理端直接查看审核队列。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
||||
|
||||
@@ -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')
|
||||
@@ -30,7 +34,7 @@ export function LoginPage({
|
||||
将后台从前台中拆分出来,同时保持迭代节奏不掉线。
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-xl text-base leading-7">
|
||||
新工作台会逐步承接运营、审核与 AI 配置,把旧的服务端渲染后台平滑替换掉。
|
||||
当前管理工作统一在这个独立后台中完成,后端专注提供 API、认证与业务规则。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -60,44 +64,58 @@ export function LoginPage({
|
||||
登录管理后台
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
当前登录仍复用后端已有管理员账号,这样可以一边迁移页面,一边保证功能持续可用。
|
||||
{localLoginEnabled
|
||||
? '当前登录复用后端管理员账号;如果前面接了 TinyAuth / Pocket ID,也可以直接由反向代理完成 SSO。'
|
||||
: proxyAuthEnabled
|
||||
? '当前后台已切到代理侧 SSO 模式,请从受保护的后台域名入口进入。'
|
||||
: '当前后台未开放本地账号密码登录,请检查部署配置。'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onLogin({ username, password })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{localLoginEnabled ? (
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void onLogin({ username, password })
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" disabled={submitting}>
|
||||
{submitting ? '登录中...' : '进入后台'}
|
||||
</Button>
|
||||
</form>
|
||||
<Button className="w-full" size="lg" disabled={submitting}>
|
||||
{submitting ? '登录中...' : '进入后台'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-7 text-muted-foreground">
|
||||
<p>推荐通过 Caddy + TinyAuth + Pocket ID 保护整个后台入口。</p>
|
||||
<p>如果你已经从受保护的后台域名进入,刷新页面后会自动识别当前 SSO 会话。</p>
|
||||
<Button className="w-full" size="lg" onClick={() => window.location.reload()}>
|
||||
重新检查会话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
|
||||
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'
|
||||
|
||||
@@ -9,6 +18,11 @@ 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) {
|
||||
@@ -30,10 +44,18 @@ export function MediaPage() {
|
||||
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 {
|
||||
@@ -62,6 +84,12 @@ export function MediaPage() {
|
||||
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) {
|
||||
@@ -70,6 +98,40 @@ export function MediaPage() {
|
||||
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">
|
||||
@@ -78,7 +140,7 @@ export function MediaPage() {
|
||||
<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>
|
||||
@@ -88,27 +150,119 @@ export function MediaPage() {
|
||||
<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>
|
||||
<CardTitle>上传与处理</CardTitle>
|
||||
<CardDescription>
|
||||
Provider:{provider ?? '未配置'} / Bucket:{bucket ?? '未配置'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-[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>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="按对象 key 搜索"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -116,64 +270,138 @@ export function MediaPage() {
|
||||
<Skeleton className="h-[520px] rounded-3xl" />
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{filteredItems.map((item) => (
|
||||
<Card key={item.key} className="overflow-hidden">
|
||||
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
|
||||
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
||||
</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}
|
||||
{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>
|
||||
<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="danger"
|
||||
disabled={deletingKey === 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))
|
||||
})
|
||||
toast.success('媒体对象已删除。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
|
||||
} finally {
|
||||
setDeletingKey(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deletingKey === item.key ? '删除中...' : '删除'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<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">
|
||||
@@ -185,6 +413,37 @@ export function MediaPage() {
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminApi, ApiError } from '@/lib/api'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
|
||||
|
||||
type PreviewState = {
|
||||
@@ -124,7 +125,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
|
||||
</Button>
|
||||
{slug ? (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={`http://localhost:4321/articles/${slug}`} target="_blank" rel="noreferrer">
|
||||
<a href={buildFrontendUrl(`/articles/${slug}`)} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开前台页面
|
||||
</a>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -38,10 +39,22 @@ 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, formatPostType, postTagsToList } from '@/lib/admin-format'
|
||||
import {
|
||||
emptyToNull,
|
||||
formatDateTime,
|
||||
formatPostStatus,
|
||||
formatPostType,
|
||||
formatPostVisibility,
|
||||
postTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
|
||||
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
|
||||
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
|
||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
AdminPostMetadataResponse,
|
||||
@@ -59,6 +72,15 @@ type PostFormState = {
|
||||
image: string
|
||||
imagesText: string
|
||||
pinned: boolean
|
||||
status: string
|
||||
visibility: string
|
||||
publishAt: string
|
||||
unpublishAt: string
|
||||
canonicalUrl: string
|
||||
noindex: boolean
|
||||
ogImage: string
|
||||
redirectFromText: string
|
||||
redirectTo: string
|
||||
tags: string
|
||||
markdown: string
|
||||
savedMarkdown: string
|
||||
@@ -73,6 +95,15 @@ type PostFormState = {
|
||||
image: string
|
||||
imagesText: string
|
||||
pinned: boolean
|
||||
status: string
|
||||
visibility: string
|
||||
publishAt: string
|
||||
unpublishAt: string
|
||||
canonicalUrl: string
|
||||
noindex: boolean
|
||||
ogImage: string
|
||||
redirectFromText: string
|
||||
redirectTo: string
|
||||
tags: string
|
||||
}
|
||||
}
|
||||
@@ -86,6 +117,15 @@ type CreatePostFormState = {
|
||||
image: string
|
||||
imagesText: string
|
||||
pinned: boolean
|
||||
status: string
|
||||
visibility: string
|
||||
publishAt: string
|
||||
unpublishAt: string
|
||||
canonicalUrl: string
|
||||
noindex: boolean
|
||||
ogImage: string
|
||||
redirectFromText: string
|
||||
redirectTo: string
|
||||
tags: string
|
||||
markdown: string
|
||||
}
|
||||
@@ -141,8 +181,6 @@ const createMetadataProposalFields: MetadataProposalField[] = [
|
||||
'category',
|
||||
'tags',
|
||||
]
|
||||
const FRONTEND_DEV_ORIGIN = 'http://localhost:4321'
|
||||
|
||||
const defaultCreateForm: CreatePostFormState = {
|
||||
title: '',
|
||||
slug: '',
|
||||
@@ -152,6 +190,15 @@ const defaultCreateForm: CreatePostFormState = {
|
||||
image: '',
|
||||
imagesText: '',
|
||||
pinned: false,
|
||||
status: 'draft',
|
||||
visibility: 'public',
|
||||
publishAt: '',
|
||||
unpublishAt: '',
|
||||
canonicalUrl: '',
|
||||
noindex: false,
|
||||
ogImage: '',
|
||||
redirectFromText: '',
|
||||
redirectTo: '',
|
||||
tags: '',
|
||||
markdown: '# 未命名文章\n',
|
||||
}
|
||||
@@ -219,11 +266,7 @@ function resolveCoverPreviewUrl(value: string) {
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
if (import.meta.env.DEV) {
|
||||
return new URL(trimmed, FRONTEND_DEV_ORIGIN).toString()
|
||||
}
|
||||
|
||||
return new URL(trimmed, window.location.origin).toString()
|
||||
return buildFrontendUrl(trimmed)
|
||||
}
|
||||
|
||||
return trimmed
|
||||
@@ -409,20 +452,29 @@ function stripFrontmatter(markdown: string) {
|
||||
return normalized.slice(endIndex + 5).trimStart()
|
||||
}
|
||||
|
||||
function extractPublishedFlag(markdown: string) {
|
||||
function extractPostStatus(markdown: string) {
|
||||
const normalized = markdown.replace(/\r\n/g, '\n')
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return true
|
||||
return 'published'
|
||||
}
|
||||
|
||||
const endIndex = normalized.indexOf('\n---\n', 4)
|
||||
if (endIndex === -1) {
|
||||
return true
|
||||
return 'published'
|
||||
}
|
||||
|
||||
const frontmatter = normalized.slice(4, endIndex)
|
||||
const match = frontmatter.match(/^published:\s*(true|false)\s*$/m)
|
||||
return match?.[1] !== 'false'
|
||||
const statusMatch = frontmatter.match(/^status:\s*(.+)\s*$/m)
|
||||
if (statusMatch?.[1]) {
|
||||
return statusMatch[1].replace(/^['"]|['"]$/g, '').trim() || 'published'
|
||||
}
|
||||
|
||||
const publishedMatch = frontmatter.match(/^published:\s*(true|false)\s*$/m)
|
||||
if (publishedMatch) {
|
||||
return publishedMatch[1] === 'false' ? 'draft' : 'published'
|
||||
}
|
||||
|
||||
return 'published'
|
||||
}
|
||||
|
||||
function buildMarkdownForSave(form: PostFormState) {
|
||||
@@ -441,7 +493,17 @@ function buildMarkdownForSave(form: PostFormState) {
|
||||
|
||||
lines.push(`post_type: ${JSON.stringify(form.postType.trim() || 'article')}`)
|
||||
lines.push(`pinned: ${form.pinned ? 'true' : 'false'}`)
|
||||
lines.push(`published: ${extractPublishedFlag(form.markdown) ? 'true' : 'false'}`)
|
||||
lines.push(`status: ${JSON.stringify(form.status.trim() || extractPostStatus(form.markdown))}`)
|
||||
lines.push(`visibility: ${JSON.stringify(form.visibility.trim() || 'public')}`)
|
||||
lines.push(`noindex: ${form.noindex ? 'true' : 'false'}`)
|
||||
|
||||
if (form.publishAt.trim()) {
|
||||
lines.push(`publish_at: ${JSON.stringify(form.publishAt.trim())}`)
|
||||
}
|
||||
|
||||
if (form.unpublishAt.trim()) {
|
||||
lines.push(`unpublish_at: ${JSON.stringify(form.unpublishAt.trim())}`)
|
||||
}
|
||||
|
||||
if (form.image.trim()) {
|
||||
lines.push(`image: ${JSON.stringify(form.image.trim())}`)
|
||||
@@ -466,6 +528,26 @@ function buildMarkdownForSave(form: PostFormState) {
|
||||
})
|
||||
}
|
||||
|
||||
if (form.canonicalUrl.trim()) {
|
||||
lines.push(`canonical_url: ${JSON.stringify(form.canonicalUrl.trim())}`)
|
||||
}
|
||||
|
||||
if (form.ogImage.trim()) {
|
||||
lines.push(`og_image: ${JSON.stringify(form.ogImage.trim())}`)
|
||||
}
|
||||
|
||||
const redirectFrom = parseImageList(form.redirectFromText)
|
||||
if (redirectFrom.length) {
|
||||
lines.push('redirect_from:')
|
||||
redirectFrom.forEach((item) => {
|
||||
lines.push(` - ${JSON.stringify(item)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (form.redirectTo.trim()) {
|
||||
lines.push(`redirect_to: ${JSON.stringify(form.redirectTo.trim())}`)
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n---\n\n${stripFrontmatter(form.markdown).trim()}\n`
|
||||
}
|
||||
|
||||
@@ -483,6 +565,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
|
||||
image: post.image ?? '',
|
||||
imagesText,
|
||||
pinned: Boolean(post.pinned),
|
||||
status: post.status ?? extractPostStatus(markdown),
|
||||
visibility: post.visibility ?? 'public',
|
||||
publishAt: post.publish_at ?? '',
|
||||
unpublishAt: post.unpublish_at ?? '',
|
||||
canonicalUrl: post.canonical_url ?? '',
|
||||
noindex: Boolean(post.noindex),
|
||||
ogImage: post.og_image ?? '',
|
||||
redirectFromText: (post.redirect_from ?? []).join('\n'),
|
||||
redirectTo: post.redirect_to ?? '',
|
||||
tags,
|
||||
markdown,
|
||||
savedMarkdown: markdown,
|
||||
@@ -497,6 +588,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
|
||||
image: post.image ?? '',
|
||||
imagesText,
|
||||
pinned: Boolean(post.pinned),
|
||||
status: post.status ?? extractPostStatus(markdown),
|
||||
visibility: post.visibility ?? 'public',
|
||||
publishAt: post.publish_at ?? '',
|
||||
unpublishAt: post.unpublish_at ?? '',
|
||||
canonicalUrl: post.canonical_url ?? '',
|
||||
noindex: Boolean(post.noindex),
|
||||
ogImage: post.og_image ?? '',
|
||||
redirectFromText: (post.redirect_from ?? []).join('\n'),
|
||||
redirectTo: post.redirect_to ?? '',
|
||||
tags,
|
||||
},
|
||||
}
|
||||
@@ -511,6 +611,15 @@ function hasMetadataDraftChanges(form: PostFormState) {
|
||||
form.image !== form.savedMeta.image ||
|
||||
form.imagesText !== form.savedMeta.imagesText ||
|
||||
form.pinned !== form.savedMeta.pinned ||
|
||||
form.status !== form.savedMeta.status ||
|
||||
form.visibility !== form.savedMeta.visibility ||
|
||||
form.publishAt !== form.savedMeta.publishAt ||
|
||||
form.unpublishAt !== form.savedMeta.unpublishAt ||
|
||||
form.canonicalUrl !== form.savedMeta.canonicalUrl ||
|
||||
form.noindex !== form.savedMeta.noindex ||
|
||||
form.ogImage !== form.savedMeta.ogImage ||
|
||||
form.redirectFromText !== form.savedMeta.redirectFromText ||
|
||||
form.redirectTo !== form.savedMeta.redirectTo ||
|
||||
form.tags !== form.savedMeta.tags
|
||||
)
|
||||
}
|
||||
@@ -534,7 +643,15 @@ function buildCreatePayload(form: CreatePostFormState): CreatePostPayload {
|
||||
image: emptyToNull(form.image),
|
||||
images: parseImageList(form.imagesText),
|
||||
pinned: form.pinned,
|
||||
published: true,
|
||||
status: emptyToNull(form.status) ?? 'draft',
|
||||
visibility: emptyToNull(form.visibility) ?? 'public',
|
||||
publishAt: emptyToNull(form.publishAt),
|
||||
unpublishAt: emptyToNull(form.unpublishAt),
|
||||
canonicalUrl: emptyToNull(form.canonicalUrl),
|
||||
noindex: form.noindex,
|
||||
ogImage: emptyToNull(form.ogImage),
|
||||
redirectFrom: parseImageList(form.redirectFromText),
|
||||
redirectTo: emptyToNull(form.redirectTo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +666,15 @@ function buildCreateMarkdownForWindow(form: CreatePostFormState) {
|
||||
image: form.image.trim(),
|
||||
images: parseImageList(form.imagesText),
|
||||
pinned: form.pinned,
|
||||
published: true,
|
||||
status: form.status.trim() || 'draft',
|
||||
visibility: form.visibility.trim() || 'public',
|
||||
publishAt: form.publishAt.trim(),
|
||||
unpublishAt: form.unpublishAt.trim(),
|
||||
canonicalUrl: form.canonicalUrl.trim(),
|
||||
noindex: form.noindex,
|
||||
ogImage: form.ogImage.trim(),
|
||||
redirectFrom: parseImageList(form.redirectFromText),
|
||||
redirectTo: form.redirectTo.trim(),
|
||||
tags: form.tags
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
@@ -571,7 +696,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
|
||||
image: parsed.meta.image || form.image,
|
||||
images: parsed.meta.images.length ? parsed.meta.images : parseImageList(form.imagesText),
|
||||
pinned: parsed.meta.pinned,
|
||||
published: extractPublishedFlag(markdown),
|
||||
status: parsed.meta.status || form.status,
|
||||
visibility: parsed.meta.visibility || form.visibility,
|
||||
publishAt: parsed.meta.publishAt || form.publishAt,
|
||||
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
|
||||
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
|
||||
noindex: parsed.meta.noindex,
|
||||
ogImage: parsed.meta.ogImage || form.ogImage,
|
||||
redirectFrom: parsed.meta.redirectFrom.length
|
||||
? parsed.meta.redirectFrom
|
||||
: parseImageList(form.redirectFromText),
|
||||
redirectTo: parsed.meta.redirectTo || form.redirectTo,
|
||||
tags: parsed.meta.tags.length
|
||||
? parsed.meta.tags
|
||||
: form.tags
|
||||
@@ -591,6 +726,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
|
||||
image: parsed.meta.image || form.image,
|
||||
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
|
||||
pinned: parsed.meta.pinned,
|
||||
status: parsed.meta.status || form.status,
|
||||
visibility: parsed.meta.visibility || form.visibility,
|
||||
publishAt: parsed.meta.publishAt || form.publishAt,
|
||||
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
|
||||
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
|
||||
noindex: parsed.meta.noindex,
|
||||
ogImage: parsed.meta.ogImage || form.ogImage,
|
||||
redirectFromText: parsed.meta.redirectFrom.length
|
||||
? parsed.meta.redirectFrom.join('\n')
|
||||
: form.redirectFromText,
|
||||
redirectTo: parsed.meta.redirectTo || form.redirectTo,
|
||||
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
|
||||
markdown: nextMarkdown,
|
||||
}
|
||||
@@ -609,6 +755,17 @@ function applyPolishedCreateState(form: CreatePostFormState, markdown: string):
|
||||
image: parsed.meta.image || form.image,
|
||||
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
|
||||
pinned: parsed.meta.pinned,
|
||||
status: parsed.meta.status || form.status,
|
||||
visibility: parsed.meta.visibility || form.visibility,
|
||||
publishAt: parsed.meta.publishAt || form.publishAt,
|
||||
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
|
||||
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
|
||||
noindex: parsed.meta.noindex,
|
||||
ogImage: parsed.meta.ogImage || form.ogImage,
|
||||
redirectFromText: parsed.meta.redirectFrom.length
|
||||
? parsed.meta.redirectFrom.join('\n')
|
||||
: form.redirectFromText,
|
||||
redirectTo: parsed.meta.redirectTo || form.redirectTo,
|
||||
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
|
||||
markdown: parsed.body || stripFrontmatter(markdown),
|
||||
}
|
||||
@@ -629,6 +786,8 @@ export function PostsPage() {
|
||||
const { slug } = useParams()
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const folderImportInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editorCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const createCoverInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [posts, setPosts] = useState<PostRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -642,6 +801,8 @@ export function PostsPage() {
|
||||
useState(false)
|
||||
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
|
||||
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
|
||||
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
|
||||
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
|
||||
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
|
||||
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
|
||||
const [editor, setEditor] = useState<PostFormState | null>(null)
|
||||
@@ -896,6 +1057,15 @@ export function PostsPage() {
|
||||
image: emptyToNull(editor.image),
|
||||
images: parseImageList(editor.imagesText),
|
||||
pinned: editor.pinned,
|
||||
status: emptyToNull(editor.status) ?? 'draft',
|
||||
visibility: emptyToNull(editor.visibility) ?? 'public',
|
||||
publishAt: emptyToNull(editor.publishAt),
|
||||
unpublishAt: emptyToNull(editor.unpublishAt),
|
||||
canonicalUrl: emptyToNull(editor.canonicalUrl),
|
||||
noindex: editor.noindex,
|
||||
ogImage: emptyToNull(editor.ogImage),
|
||||
redirectFrom: parseImageList(editor.redirectFromText),
|
||||
redirectTo: emptyToNull(editor.redirectTo),
|
||||
})
|
||||
|
||||
const updatedMarkdown = await adminApi.updatePostMarkdown(editor.slug, persistedMarkdown)
|
||||
@@ -1082,6 +1252,68 @@ export function PostsPage() {
|
||||
}
|
||||
}, [createForm])
|
||||
|
||||
const uploadEditorCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingEditorCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '文章封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setEditor((current) => (current ? { ...current, image: url } : current))
|
||||
})
|
||||
toast.success('封面已上传并回填。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
} finally {
|
||||
setUploadingEditorCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const uploadCreateCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCreateCover(true)
|
||||
const compressed = await normalizeCoverImageWithPrompt(file, {
|
||||
quality: 0.82,
|
||||
ask: true,
|
||||
contextLabel: '新建封面规范化上传',
|
||||
})
|
||||
if (compressed.preview) {
|
||||
toast.message(formatCompressionPreview(compressed.preview))
|
||||
}
|
||||
|
||||
const result = await adminApi.uploadMediaObjects([compressed.file], {
|
||||
prefix: 'post-covers/',
|
||||
})
|
||||
const url = result.uploaded[0]?.url
|
||||
if (!url) {
|
||||
throw new Error('上传完成但未返回 URL')
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setCreateForm((current) => ({ ...current, image: url }))
|
||||
})
|
||||
toast.success('封面已上传并回填。')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
|
||||
} finally {
|
||||
setUploadingCreateCover(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const editorPolishHunks = useMemo(
|
||||
() =>
|
||||
editorPolish
|
||||
@@ -1596,6 +1828,32 @@ export function PostsPage() {
|
||||
void importMarkdownFiles(event.target.files)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={editorCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadEditorCover(file)
|
||||
}
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={createCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
void uploadCreateCover(file)
|
||||
}
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
@@ -1842,7 +2100,10 @@ export function PostsPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-xl">{editor.title || editor.slug}</CardTitle>
|
||||
<Badge variant="secondary">{formatPostType(editor.postType)}</Badge>
|
||||
<Badge variant="outline">{formatPostStatus(editor.status)}</Badge>
|
||||
<Badge variant="outline">{formatPostVisibility(editor.visibility)}</Badge>
|
||||
{editor.pinned ? <Badge variant="success">置顶</Badge> : null}
|
||||
{editor.noindex ? <Badge variant="warning">noindex</Badge> : null}
|
||||
{markdownDirty ? <Badge variant="warning">未保存</Badge> : null}
|
||||
</div>
|
||||
<CardDescription className="font-mono text-xs">{editor.slug}</CardDescription>
|
||||
@@ -1912,6 +2173,60 @@ export function PostsPage() {
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<FormField label="发布状态">
|
||||
<Select
|
||||
value={editor.status}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, status: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">发布</option>
|
||||
<option value="offline">下线</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="可见性">
|
||||
<Select
|
||||
value={editor.visibility}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, visibility: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="public">公开</option>
|
||||
<option value="unlisted">不公开(直链)</option>
|
||||
<option value="private">私有(仅后台预览)</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<FormField label="定时发布">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={editor.publishAt}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, publishAt: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="下线时间">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={editor.unpublishAt}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, unpublishAt: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
|
||||
<Input
|
||||
value={editor.tags}
|
||||
@@ -1954,10 +2269,18 @@ export function PostsPage() {
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => editorCoverInputRef.current?.click()}
|
||||
disabled={uploadingEditorCover}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingEditorCover ? '上传中...' : '上传封面'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void generateEditorCover()}
|
||||
disabled={generatingEditorCover}
|
||||
disabled={generatingEditorCover || uploadingEditorCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingEditorCover
|
||||
@@ -1998,6 +2321,64 @@ export function PostsPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Canonical URL" hint="留空则使用默认文章地址。">
|
||||
<Input
|
||||
value={editor.canonicalUrl}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, canonicalUrl: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="OG 图 URL" hint="留空则前台自动生成 SVG 分享图。">
|
||||
<Input
|
||||
value={editor.ogImage}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, ogImage: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="旧地址重定向" hint="每行一个旧 slug,不带 /articles/ 前缀。">
|
||||
<Textarea
|
||||
value={editor.redirectFromText}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, redirectFromText: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="强制跳转目标" hint="适合旧文跳新文;留空表示当前 slug 为主地址。">
|
||||
<Input
|
||||
value={editor.redirectTo}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, redirectTo: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editor.noindex}
|
||||
onChange={(event) =>
|
||||
setEditor((current) =>
|
||||
current ? { ...current, noindex: event.target.checked } : current,
|
||||
)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">搜索引擎不收录(noindex)</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
适合活动页、临时内容或只想保留直链访问的文章。
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2200,6 +2581,8 @@ export function PostsPage() {
|
||||
<Badge variant="outline">{createForm.markdown.split(/\r?\n/).length} 行</Badge>
|
||||
<Badge variant="secondary">AI 元信息对比回填</Badge>
|
||||
<Badge variant="outline">AI 封面</Badge>
|
||||
<Badge variant="outline">{formatPostStatus(createForm.status)}</Badge>
|
||||
<Badge variant="outline">{formatPostVisibility(createForm.visibility)}</Badge>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-6 text-muted-foreground">
|
||||
先写正文,再让 AI 生成标题、摘要、分类、标签和 slug;确认后顺手生成封面图,最后再润色和创建草稿。
|
||||
@@ -2252,6 +2635,52 @@ export function PostsPage() {
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<FormField label="发布状态">
|
||||
<Select
|
||||
value={createForm.status}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">发布</option>
|
||||
<option value="offline">下线</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="可见性">
|
||||
<Select
|
||||
value={createForm.visibility}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, visibility: event.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="public">公开</option>
|
||||
<option value="unlisted">不公开(直链)</option>
|
||||
<option value="private">私有(仅后台预览)</option>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<FormField label="定时发布">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={createForm.publishAt}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, publishAt: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="下线时间">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={createForm.unpublishAt}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, unpublishAt: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
|
||||
<Input
|
||||
value={createForm.tags}
|
||||
@@ -2291,10 +2720,18 @@ export function PostsPage() {
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => createCoverInputRef.current?.click()}
|
||||
disabled={uploadingCreateCover}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadingCreateCover ? '上传中...' : '上传封面'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void generateCreateCover()}
|
||||
disabled={generatingCreateCover}
|
||||
disabled={generatingCreateCover || uploadingCreateCover}
|
||||
>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
{generatingCreateCover
|
||||
@@ -2333,6 +2770,57 @@ export function PostsPage() {
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Canonical URL" hint="留空时使用默认文章地址。">
|
||||
<Input
|
||||
value={createForm.canonicalUrl}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, canonicalUrl: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="OG 图 URL" hint="留空则由前台自动生成。">
|
||||
<Input
|
||||
value={createForm.ogImage}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, ogImage: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="旧地址重定向" hint="每行一个旧 slug。">
|
||||
<Textarea
|
||||
value={createForm.redirectFromText}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
redirectFromText: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="强制跳转目标" hint="可选:创建即作为跳转占位。">
|
||||
<Input
|
||||
value={createForm.redirectTo}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, redirectTo: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.noindex}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, noindex: event.target.checked }))
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">创建时设置 noindex</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
适合临时活动页、内测内容或不希望进 sitemap / RSS 的文章。
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
formatReviewType,
|
||||
reviewTagsToList,
|
||||
} from '@/lib/admin-format'
|
||||
import {
|
||||
formatCompressionPreview,
|
||||
normalizeCoverImageWithPrompt,
|
||||
} from '@/lib/image-compress'
|
||||
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
|
||||
|
||||
type ReviewFormState = {
|
||||
@@ -216,7 +220,15 @@ export function ReviewsPage() {
|
||||
const uploadReviewCover = useCallback(async (file: File) => {
|
||||
try {
|
||||
setUploadingCover(true)
|
||||
const result = await adminApi.uploadReviewCoverImage(file)
|
||||
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 }))
|
||||
})
|
||||
@@ -506,7 +518,7 @@ export function ReviewsPage() {
|
||||
<input
|
||||
ref={reviewCoverInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -83,10 +83,12 @@ function normalizeSettingsResponse(
|
||||
input: AdminSiteSettingsResponse,
|
||||
): AdminSiteSettingsResponse {
|
||||
const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : []
|
||||
const searchSynonyms = Array.isArray(input.search_synonyms) ? input.search_synonyms : []
|
||||
|
||||
return {
|
||||
...input,
|
||||
ai_providers: aiProviders,
|
||||
search_synonyms: searchSynonyms,
|
||||
ai_active_provider_id:
|
||||
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
||||
}
|
||||
@@ -151,6 +153,12 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
||||
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
|
||||
mediaR2AccessKeyId: form.media_r2_access_key_id,
|
||||
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
|
||||
seoDefaultOgImage: form.seo_default_og_image,
|
||||
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
|
||||
notificationWebhookUrl: form.notification_webhook_url,
|
||||
notificationCommentEnabled: form.notification_comment_enabled,
|
||||
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
|
||||
searchSynonyms: form.search_synonyms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +595,94 @@ export function SiteSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SEO、搜索与通知</CardTitle>
|
||||
<CardDescription>
|
||||
统一维护默认 OG 图、Twitter 标识、Webhook 通知与搜索同义词。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。">
|
||||
<Input
|
||||
value={form.seo_default_og_image ?? ''}
|
||||
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Twitter / X Handle" hint="例如 @initcool。">
|
||||
<Input
|
||||
value={form.seo_default_twitter_handle ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('seo_default_twitter_handle', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="lg:col-span-2">
|
||||
<Field label="Webhook URL" hint="评论和友链申请会向这个地址推送 JSON。">
|
||||
<Input
|
||||
value={form.notification_webhook_url ?? ''}
|
||||
onChange={(event) =>
|
||||
updateField('notification_webhook_url', event.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="lg:col-span-2 grid gap-4 md:grid-cols-2">
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.notification_comment_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('notification_comment_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">新评论通知</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
有新评论创建时,通过 Webhook 推送待审核提醒。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.notification_friend_link_enabled}
|
||||
onChange={(event) =>
|
||||
updateField('notification_friend_link_enabled', event.target.checked)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">友链申请通知</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
有新的友链申请时,同样通过 Webhook 推送。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<Field
|
||||
label="搜索同义词"
|
||||
hint="每行一组,逗号分隔。例如:ai, llm, gpt 或 rust, cargo, crates。"
|
||||
>
|
||||
<Textarea
|
||||
value={form.search_synonyms.join('\n')}
|
||||
onChange={(event) =>
|
||||
updateField(
|
||||
'search_synonyms',
|
||||
event.target.value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>互动功能</CardTitle>
|
||||
|
||||
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
|
||||
|
||||
157
backend/Cargo.lock
generated
157
backend/Cargo.lock
generated
@@ -2202,76 +2202,6 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-bundle"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4"
|
||||
dependencies = [
|
||||
"fluent-langneg",
|
||||
"fluent-syntax",
|
||||
"intl-memoizer",
|
||||
"intl_pluralrules",
|
||||
"rustc-hash",
|
||||
"self_cell",
|
||||
"smallvec",
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-langneg"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0"
|
||||
dependencies = [
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-syntax"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-template-macros"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "748050b3fb6fd97b566aedff8e9e021389c963e73d5afbeb92752c2b8d686c6c"
|
||||
dependencies = [
|
||||
"flume",
|
||||
"ignore",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-templates"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56264446a01f404469aef9cc5fd4a4d736f68a0f52482bf6d1a54d6e9bbd9476"
|
||||
dependencies = [
|
||||
"fluent-bundle",
|
||||
"fluent-langneg",
|
||||
"fluent-syntax",
|
||||
"fluent-template-macros",
|
||||
"flume",
|
||||
"heck 0.5.0",
|
||||
"ignore",
|
||||
"intl-memoizer",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tera",
|
||||
"thiserror 2.0.18",
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -3296,25 +3226,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intl-memoizer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
|
||||
dependencies = [
|
||||
"type-map",
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intl_pluralrules"
|
||||
version = "7.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
|
||||
dependencies = [
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -4631,12 +4542,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-hack"
|
||||
version = "0.5.20+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -5724,12 +5629,6 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -6566,7 +6465,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"fastembed",
|
||||
"fluent-templates",
|
||||
"include_dir",
|
||||
"insta",
|
||||
"loco-rs",
|
||||
@@ -6583,7 +6481,6 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unic-langid",
|
||||
"uuid",
|
||||
"validator",
|
||||
]
|
||||
@@ -6689,7 +6586,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -7079,15 +6975,6 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
|
||||
dependencies = [
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -7110,49 +6997,6 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
|
||||
dependencies = [
|
||||
"unic-langid-impl",
|
||||
"unic-langid-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid-impl"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
|
||||
dependencies = [
|
||||
"tinystr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid-macros"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25"
|
||||
dependencies = [
|
||||
"proc-macro-hack",
|
||||
"tinystr",
|
||||
"unic-langid-impl",
|
||||
"unic-langid-macros-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid-macros-impl"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5"
|
||||
dependencies = [
|
||||
"proc-macro-hack",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"unic-langid-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@@ -8183,7 +8027,6 @@ version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
@@ -36,10 +36,6 @@ chrono = { version = "0.4" }
|
||||
validator = { version = "0.20" }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
include_dir = { version = "0.7" }
|
||||
# view engine i18n
|
||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||
unic-langid = { version = "0.9" }
|
||||
# /view engine
|
||||
axum-extra = { version = "0.10", features = ["form"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] }
|
||||
|
||||
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,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,113 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block main_content %}
|
||||
<section class="form-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>新增评价</h2>
|
||||
<div class="table-note">这里创建的评价会立刻出现在前台 `/reviews` 页面。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/reviews" class="inline-form">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="title" placeholder="标题" value="{{ create_form.title }}" required>
|
||||
<select name="review_type">
|
||||
<option value="game" {% if create_form.review_type == "game" %}selected{% endif %}>游戏</option>
|
||||
<option value="anime" {% if create_form.review_type == "anime" %}selected{% endif %}>动画</option>
|
||||
<option value="music" {% if create_form.review_type == "music" %}selected{% endif %}>音乐</option>
|
||||
<option value="book" {% if create_form.review_type == "book" %}selected{% endif %}>书籍</option>
|
||||
<option value="movie" {% if create_form.review_type == "movie" %}selected{% endif %}>影视</option>
|
||||
</select>
|
||||
<input type="number" name="rating" min="0" max="5" value="{{ create_form.rating }}" required>
|
||||
<input type="date" name="review_date" value="{{ create_form.review_date }}">
|
||||
<select name="status">
|
||||
<option value="completed" {% if create_form.status == "completed" %}selected{% endif %}>已完成</option>
|
||||
<option value="in-progress" {% if create_form.status == "in-progress" %}selected{% endif %}>进行中</option>
|
||||
<option value="dropped" {% if create_form.status == "dropped" %}selected{% endif %}>已弃坑</option>
|
||||
</select>
|
||||
<input type="text" name="cover" value="{{ create_form.cover }}" placeholder="封面图标或 emoji">
|
||||
<input type="url" name="link_url" value="{{ create_form.link_url }}" placeholder="跳转链接,可选">
|
||||
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="标签,逗号分隔">
|
||||
<textarea name="description" placeholder="评价描述">{{ create_form.description }}</textarea>
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-primary">创建评价</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-head">
|
||||
<div>
|
||||
<h2>评价列表</h2>
|
||||
<div class="table-note">这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if rows | length > 0 %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>评价内容</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="mono">#{{ row.id }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/reviews/{{ row.id }}/update" class="inline-form compact">
|
||||
<div class="compact-grid">
|
||||
<input type="text" name="title" value="{{ row.title }}" required>
|
||||
<select name="review_type">
|
||||
<option value="game" {% if row.review_type == "game" %}selected{% endif %}>游戏</option>
|
||||
<option value="anime" {% if row.review_type == "anime" %}selected{% endif %}>动画</option>
|
||||
<option value="music" {% if row.review_type == "music" %}selected{% endif %}>音乐</option>
|
||||
<option value="book" {% if row.review_type == "book" %}selected{% endif %}>书籍</option>
|
||||
<option value="movie" {% if row.review_type == "movie" %}selected{% endif %}>影视</option>
|
||||
</select>
|
||||
<input type="number" name="rating" min="0" max="5" value="{{ row.rating }}" required>
|
||||
<input type="date" name="review_date" value="{{ row.review_date }}">
|
||||
<select name="status">
|
||||
<option value="completed" {% if row.status == "completed" %}selected{% endif %}>已完成</option>
|
||||
<option value="in-progress" {% if row.status == "in-progress" %}selected{% endif %}>进行中</option>
|
||||
<option value="dropped" {% if row.status == "dropped" %}selected{% endif %}>已弃坑</option>
|
||||
</select>
|
||||
<input type="text" name="cover" value="{{ row.cover }}" placeholder="封面图标或 emoji">
|
||||
<input type="url" name="link_url" value="{{ row.link_url }}" placeholder="跳转链接,可选">
|
||||
<input type="text" name="tags" value="{{ row.tags_input }}" placeholder="标签,逗号分隔">
|
||||
<textarea name="description" placeholder="评价描述">{{ row.description }}</textarea>
|
||||
</div>
|
||||
<div class="compact-actions">
|
||||
<button type="submit" class="btn btn-success">保存</button>
|
||||
{% if row.link_url %}
|
||||
<a href="{{ row.link_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">跳转</a>
|
||||
{% endif %}
|
||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="chip">{{ row.status }}</span></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a href="http://localhost:4321/reviews" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台查看</a>
|
||||
<form method="post" action="/admin/reviews/{{ row.id }}/delete">
|
||||
<button type="submit" class="btn btn-danger">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">暂无评价数据。</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,225 +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>接入类型 / 协议</label>
|
||||
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
|
||||
<div class="field-hint">这里是后端适配器类型,不是模型厂商名。`newapi` 表示走 NewAPI 兼容的 Responses 接口;厂商和型号建议写在你的通道备注与模型名里。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天 API Base</label>
|
||||
<input name="ai_api_base" value="{{ form.ai_api_base }}" placeholder="https://91code.jiangnight.com/v1">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>聊天 API Key</label>
|
||||
<input name="ai_api_key" value="{{ form.ai_api_key }}" placeholder="sk-...">
|
||||
<div class="field-hint">这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入 91code.jiangnight.com 的 NewAPI 兼容接口,未配置时前台仍可做本地检索,但不会生成完整聊天回答。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>聊天模型</label>
|
||||
<input name="ai_chat_model" value="{{ form.ai_chat_model }}" placeholder="gpt-5.4">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>本地 Embedding</label>
|
||||
<input value="{{ form.ai_local_embedding }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Top K</label>
|
||||
<input type="number" min="1" max="12" name="ai_top_k" value="{{ form.ai_top_k }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Chunk Size</label>
|
||||
<input type="number" min="400" max="4000" step="50" name="ai_chunk_size" value="{{ form.ai_chunk_size }}">
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<label>系统提示词</label>
|
||||
<textarea name="ai_system_prompt">{{ form.ai_system_prompt }}</textarea>
|
||||
</div>
|
||||
<div class="field field-wide">
|
||||
<div class="table-note">AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。前台用户提交过的搜索词和 AI 问题会单独写入分析日志,方便在新版后台里查看。</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>
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
Compiling proc-macro2 v1.0.106
|
||||
Compiling quote v1.0.45
|
||||
Compiling unicode-ident v1.0.24
|
||||
Compiling serde_core v1.0.228
|
||||
Compiling serde v1.0.228
|
||||
Compiling getrandom v0.3.4
|
||||
Compiling autocfg v1.5.0
|
||||
Compiling find-msvc-tools v0.1.9
|
||||
Compiling shlex v1.3.0
|
||||
Compiling version_check v0.9.5
|
||||
Compiling crossbeam-utils v0.8.21
|
||||
Compiling zmij v1.0.21
|
||||
Compiling zerocopy v0.8.47
|
||||
Compiling serde_json v1.0.149
|
||||
Compiling pkg-config v0.3.32
|
||||
Compiling icu_normalizer_data v2.1.1
|
||||
Compiling icu_properties_data v2.1.2
|
||||
Compiling thiserror v2.0.18
|
||||
Compiling libc v0.2.183
|
||||
Compiling typenum v1.19.0
|
||||
Compiling generic-array v0.14.7
|
||||
Compiling rustls v0.23.37
|
||||
Compiling num-traits v0.2.19
|
||||
Compiling libm v0.2.16
|
||||
Compiling getrandom v0.4.2
|
||||
Compiling windows_x86_64_msvc v0.52.6
|
||||
Compiling jobserver v0.1.34
|
||||
Compiling ident_case v1.0.1
|
||||
Compiling parking_lot_core v0.9.12
|
||||
Compiling regex-syntax v0.8.10
|
||||
Compiling crc32fast v1.5.0
|
||||
Compiling httparse v1.10.1
|
||||
Compiling bigdecimal v0.4.10
|
||||
Compiling cc v1.2.57
|
||||
Compiling crossbeam-epoch v0.9.18
|
||||
Compiling rust_decimal v1.40.0
|
||||
Compiling windows-targets v0.52.6
|
||||
Compiling rand v0.10.0
|
||||
Compiling proc-macro-hack v0.5.20+deprecated
|
||||
Compiling crossbeam-deque v0.8.6
|
||||
Compiling rand_core v0.6.4
|
||||
Compiling windows_x86_64_msvc v0.48.5
|
||||
Compiling flate2 v1.1.9
|
||||
Compiling windows_x86_64_msvc v0.53.1
|
||||
Compiling syn v2.0.117
|
||||
Compiling rand v0.8.5
|
||||
Compiling rayon-core v1.13.0
|
||||
Compiling regex-automata v0.4.14
|
||||
Compiling num-integer v0.1.46
|
||||
Compiling zstd-safe v7.2.4
|
||||
Compiling windows-sys v0.59.0
|
||||
Compiling concurrent-queue v2.5.0
|
||||
Compiling log v0.4.29
|
||||
Compiling num-bigint v0.4.6
|
||||
Compiling phf_generator v0.11.3
|
||||
Compiling block-buffer v0.10.4
|
||||
Compiling crypto-common v0.1.7
|
||||
Compiling winapi v0.3.9
|
||||
Compiling vcpkg v0.2.15
|
||||
Compiling anyhow v1.0.102
|
||||
Compiling native-tls v0.2.18
|
||||
Compiling digest v0.10.7
|
||||
Compiling object v0.37.3
|
||||
Compiling phf_codegen v0.11.3
|
||||
Compiling sha2 v0.10.9
|
||||
Compiling event-listener v5.4.1
|
||||
Compiling hashbrown v0.16.1
|
||||
Compiling deranged v0.5.8
|
||||
Compiling uuid v1.23.0
|
||||
Compiling ring v0.17.14
|
||||
Compiling zstd-sys v2.0.16+zstd.1.5.7
|
||||
Compiling windows-targets v0.53.5
|
||||
Compiling libsqlite3-sys v0.30.1
|
||||
Compiling windows-targets v0.48.5
|
||||
Compiling crossbeam-queue v0.3.12
|
||||
Compiling ahash v0.8.12
|
||||
Compiling windows-sys v0.48.0
|
||||
Compiling indexmap v2.13.0
|
||||
Compiling windows-sys v0.60.2
|
||||
Compiling time v0.3.47
|
||||
Compiling hmac v0.12.1
|
||||
Compiling regex v1.12.3
|
||||
Compiling md-5 v0.10.6
|
||||
Compiling atoi v2.0.0
|
||||
Compiling proc-macro-error-attr2 v2.0.0
|
||||
Compiling rustversion v1.0.22
|
||||
Compiling parse-zoneinfo v0.3.1
|
||||
Compiling etcetera v0.8.0
|
||||
Compiling hkdf v0.12.4
|
||||
Compiling rand_core v0.9.5
|
||||
Compiling chrono-tz-build v0.3.0
|
||||
Compiling proc-macro2-diagnostics v0.10.1
|
||||
Compiling portable-atomic v1.13.1
|
||||
Compiling base64ct v1.8.3
|
||||
Compiling socks v0.3.4
|
||||
Compiling paste v1.0.15
|
||||
Compiling pem-rfc7468 v1.0.0
|
||||
Compiling ignore v0.4.25
|
||||
Compiling ordered-float v4.6.0
|
||||
Compiling yansi v1.0.1
|
||||
Compiling thiserror v1.0.69
|
||||
Compiling ureq-proto v0.6.0
|
||||
Compiling der v0.8.0
|
||||
Compiling globwalk v0.9.1
|
||||
Compiling stacker v0.1.23
|
||||
Compiling num-rational v0.4.2
|
||||
Compiling humansize v2.1.3
|
||||
Compiling fs-err v2.11.0
|
||||
Compiling synstructure v0.13.2
|
||||
Compiling darling_core v0.20.11
|
||||
Compiling proc-macro-error2 v2.0.1
|
||||
Compiling pest_generator v2.8.6
|
||||
Compiling multer v3.1.0
|
||||
Compiling chrono-tz v0.9.0
|
||||
Compiling av-scenechange v0.14.1
|
||||
Compiling utf8-zero v0.8.1
|
||||
Compiling unicode-xid v0.2.6
|
||||
Compiling built v0.8.0
|
||||
Compiling ureq v3.3.0
|
||||
Compiling shared_child v1.1.1
|
||||
Compiling onig_sys v69.9.1
|
||||
Compiling matrixmultiply v0.3.10
|
||||
Compiling cookie v0.18.1
|
||||
Compiling hmac-sha256 v1.1.14
|
||||
Compiling rav1e v0.8.1
|
||||
Compiling pastey v0.1.1
|
||||
Compiling lzma-rust2 v0.15.7
|
||||
Compiling duct v1.1.1
|
||||
Compiling serde_path_to_error v0.1.20
|
||||
Compiling ar_archive_writer v0.5.1
|
||||
Compiling simd_helpers v0.1.0
|
||||
Compiling include_dir_macros v0.7.4
|
||||
Compiling windows-sys v0.52.0
|
||||
Compiling crossbeam-channel v0.5.15
|
||||
Compiling esaxx-rs v0.1.10
|
||||
Compiling tokio-cron-scheduler v0.11.1
|
||||
Compiling noop_proc_macro v0.3.0
|
||||
Compiling console v0.15.11
|
||||
Compiling include_dir v0.7.4
|
||||
Compiling castaway v0.2.4
|
||||
Compiling globset v0.4.18
|
||||
Compiling serde_derive v1.0.228
|
||||
Compiling displaydoc v0.2.5
|
||||
Compiling zerofrom-derive v0.1.6
|
||||
Compiling yoke-derive v0.8.1
|
||||
Compiling zerovec-derive v0.11.2
|
||||
Compiling tokio-macros v2.6.1
|
||||
Compiling tracing-attributes v0.1.31
|
||||
Compiling zerocopy-derive v0.8.47
|
||||
Compiling thiserror-impl v2.0.18
|
||||
Compiling futures-macro v0.3.32
|
||||
Compiling rustls-webpki v0.103.10
|
||||
Compiling darling_macro v0.20.11
|
||||
Compiling tinystr v0.8.2
|
||||
Compiling tokio v1.50.0
|
||||
Compiling unic-langid-impl v0.9.6
|
||||
Compiling equator-macro v0.4.2
|
||||
Compiling psm v0.1.30
|
||||
Compiling zerofrom v0.1.6
|
||||
Compiling darling v0.20.11
|
||||
Compiling futures-util v0.3.32
|
||||
Compiling yoke v0.8.1
|
||||
Compiling inherent v1.0.13
|
||||
Compiling num-derive v0.4.2
|
||||
Compiling tracing v0.1.44
|
||||
Compiling unic-langid-macros-impl v0.9.6
|
||||
Compiling zerovec v0.11.5
|
||||
Compiling zerotrie v0.2.3
|
||||
Compiling equator v0.4.2
|
||||
Compiling clap_derive v4.6.0
|
||||
Compiling pest_derive v2.8.6
|
||||
Compiling sea-query-derive v0.4.3
|
||||
Compiling aligned-vec v0.6.4
|
||||
Compiling thiserror-impl v1.0.69
|
||||
Compiling v_frame v0.3.9
|
||||
Compiling sea-bae v0.2.1
|
||||
Compiling async-trait v0.1.89
|
||||
Compiling profiling-procmacros v1.0.17
|
||||
Compiling derive_more-impl v2.1.1
|
||||
Compiling potential_utf v0.1.4
|
||||
Compiling icu_locale_core v2.1.1
|
||||
Compiling icu_collections v2.1.1
|
||||
Compiling arg_enum_proc_macro v0.3.4
|
||||
Compiling unic-langid-macros v0.9.6
|
||||
Compiling futures-executor v0.3.32
|
||||
Compiling futures v0.3.32
|
||||
Compiling icu_provider v2.1.1
|
||||
Compiling unic-langid v0.9.6
|
||||
Compiling smallvec v1.15.1
|
||||
Compiling chrono v0.4.44
|
||||
Compiling either v1.15.0
|
||||
Compiling serde_urlencoded v0.7.1
|
||||
Compiling icu_properties v2.1.2
|
||||
Compiling tracing-serde v0.2.0
|
||||
Compiling icu_normalizer v2.1.1
|
||||
Compiling tokio-util v0.7.18
|
||||
Compiling tokio-stream v0.1.18
|
||||
Compiling tower v0.5.3
|
||||
Compiling parking_lot v0.12.5
|
||||
Compiling rayon v1.11.0
|
||||
Compiling tokio-rustls v0.26.4
|
||||
Compiling idna_adapter v1.2.1
|
||||
Compiling h2 v0.4.13
|
||||
Compiling ppv-lite86 v0.2.21
|
||||
Compiling futures-intrusive v0.5.0
|
||||
Compiling idna v1.1.0
|
||||
Compiling tokio-native-tls v0.3.1
|
||||
Compiling sea-query v0.32.7
|
||||
Compiling rand_chacha v0.3.1
|
||||
Compiling rand_chacha v0.9.0
|
||||
Compiling itertools v0.14.0
|
||||
Compiling url v2.5.8
|
||||
Compiling hashbrown v0.14.5
|
||||
Compiling rand v0.9.2
|
||||
Compiling clap v4.6.0
|
||||
Compiling sqlx-core v0.8.6
|
||||
Compiling tracing-subscriber v0.3.23
|
||||
Compiling async-stream-impl v0.3.6
|
||||
Compiling ouroboros_macro v0.18.5
|
||||
Compiling maybe-rayon v0.1.1
|
||||
Compiling half v2.7.1
|
||||
Compiling derive_more v2.1.1
|
||||
Compiling serde_spanned v0.6.9
|
||||
Compiling serde_regex v1.1.0
|
||||
Compiling serde_yaml v0.9.34+deprecated
|
||||
Compiling toml_datetime v0.6.11
|
||||
Compiling tera v1.20.1
|
||||
Compiling async-stream v0.3.6
|
||||
Compiling sea-orm-macros v1.1.19
|
||||
Compiling profiling v1.0.17
|
||||
Compiling av1-grain v0.2.5
|
||||
Compiling hyper v1.8.1
|
||||
Compiling axum-core v0.5.6
|
||||
Compiling derive_builder_core v0.20.2
|
||||
Compiling sqlx-postgres v0.8.6
|
||||
Compiling sqlx-sqlite v0.8.6
|
||||
Compiling hyper-util v0.1.20
|
||||
Compiling ouroboros v0.18.5
|
||||
Compiling ort-sys v2.0.0-rc.11
|
||||
Compiling fax_derive v0.2.0
|
||||
Compiling axum-macros v0.5.0
|
||||
Compiling sea-schema-derive v0.3.0
|
||||
Compiling fax v0.2.6
|
||||
Compiling hyper-tls v0.6.0
|
||||
Compiling hyper-rustls v0.27.7
|
||||
Compiling rrgen v0.5.6
|
||||
Compiling derive_builder_macro v0.20.2
|
||||
Compiling chumsky v0.9.3
|
||||
Compiling sea-orm-cli v1.1.19
|
||||
Compiling toml_edit v0.22.27
|
||||
Compiling combine v4.6.7
|
||||
Compiling cron v0.12.1
|
||||
Compiling backon v1.6.0
|
||||
Compiling quick-xml v0.38.4
|
||||
Compiling simple_asn1 v0.6.4
|
||||
Compiling validator_derive v0.20.0
|
||||
Compiling socket2 v0.5.10
|
||||
Compiling monostate-impl v0.1.18
|
||||
Compiling serde_html_form v0.2.8
|
||||
Compiling sqlx v0.8.6
|
||||
Compiling colored v2.2.0
|
||||
Compiling blake2 v0.10.6
|
||||
Compiling sea-query-binder v0.7.0
|
||||
Compiling num-complex v0.4.6
|
||||
Compiling macro_rules_attribute-proc_macro v0.2.2
|
||||
Compiling loco-rs v0.16.4
|
||||
Compiling moxcms v0.8.1
|
||||
Compiling axum v0.8.8
|
||||
Compiling sea-schema v0.16.2
|
||||
Compiling sea-orm v1.1.19
|
||||
Compiling validator v0.20.0
|
||||
Compiling ndarray v0.17.2
|
||||
Compiling macro_rules_attribute v0.2.2
|
||||
Compiling spm_precompiled v0.1.4
|
||||
Compiling lettre v0.11.19
|
||||
Compiling exr v1.74.0
|
||||
Compiling backtrace_printer v1.3.0
|
||||
Compiling zstd v0.13.3
|
||||
Compiling moka v0.12.15
|
||||
Compiling compression-codecs v0.4.37
|
||||
Compiling ravif v0.13.0
|
||||
Compiling async-compression v0.4.41
|
||||
Compiling redis v0.31.0
|
||||
Compiling tower-http v0.6.8
|
||||
Compiling indicatif v0.17.11
|
||||
Compiling argon2 v0.5.3
|
||||
Compiling reqwest v0.12.28
|
||||
Compiling axum-extra v0.10.3
|
||||
Compiling byte-unit v4.0.19
|
||||
Compiling loco-gen v0.16.4
|
||||
Compiling jsonwebtoken v9.3.1
|
||||
Compiling notify v8.2.0
|
||||
Compiling png v0.18.1
|
||||
Compiling monostate v0.1.18
|
||||
Compiling toml v0.8.23
|
||||
Compiling onig v6.5.1
|
||||
Compiling derive_builder v0.20.2
|
||||
Compiling tiff v0.11.3
|
||||
Compiling tracing-appender v0.2.4
|
||||
Compiling opendal v0.54.1
|
||||
Compiling rayon-cond v0.4.0
|
||||
Compiling ulid v1.2.1
|
||||
Compiling dashmap v6.1.0
|
||||
Compiling ureq v2.12.1
|
||||
Compiling unicode-normalization-alignments v0.1.12
|
||||
Compiling intl_pluralrules v7.0.2
|
||||
Compiling intl-memoizer v0.5.3
|
||||
Compiling fluent-langneg v0.13.1
|
||||
Compiling compact_str v0.9.0
|
||||
Compiling ipnetwork v0.20.0
|
||||
Compiling dary_heap v0.3.8
|
||||
Compiling serde_variant v0.1.3
|
||||
Compiling fluent-syntax v0.12.0
|
||||
Compiling tower v0.4.13
|
||||
Compiling duct_sh v1.0.0
|
||||
Compiling fluent-bundle v0.16.0
|
||||
Compiling tokenizers v0.22.2
|
||||
Compiling hf-hub v0.4.3
|
||||
Compiling image v0.25.10
|
||||
Compiling ort v2.0.0-rc.11
|
||||
Compiling safetensors v0.7.0
|
||||
Compiling sea-orm-migration v1.1.19
|
||||
Compiling fluent-template-macros v0.13.3
|
||||
Compiling fluent-templates v0.13.3
|
||||
Compiling fastembed v5.13.0
|
||||
Compiling migration v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend\migration)
|
||||
Compiling termi-api v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 23s
|
||||
Running `target\debug\termi_api-cli.exe start`
|
||||
error: process didn't exit successfully: `target\debug\termi_api-cli.exe start` (exit code: 0xffffffff)
|
||||
@@ -1,26 +0,0 @@
|
||||
[2m2026-03-29T11:49:41.902355Z[0m [33m WARN[0m [2mloco_rs::boot[0m[2m:[0m pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml)
|
||||
|
||||
▄ ▀
|
||||
▀ ▄
|
||||
▄ ▀ ▄ ▄ ▄▀
|
||||
▄ ▀▄▄
|
||||
▄ ▀ ▀ ▀▄▀█▄
|
||||
▀█▄
|
||||
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
|
||||
██████ █████ ███ █████ ███ █████ ███ ▀█
|
||||
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
||||
██████ █████ ███ █████ █████ ███ ████▄
|
||||
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
||||
██████ █████ ███ ████ ███ █████ ███ ████▀
|
||||
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
https://loco.rs
|
||||
|
||||
environment: development
|
||||
database: automigrate
|
||||
logger: debug
|
||||
compilation: debug
|
||||
modes: server
|
||||
|
||||
listening on http://localhost:5150
|
||||
[2m2026-03-29T11:50:40.675162Z[0m [31mERROR[0m [1mhttp-request[0m: [2mloco_rs::controller[0m[2m:[0m controller_error [3merror.msg[0m[2m=[0mAI provider returned 429 Too Many Requests: {"error":{"message":"Concurrency limit exceeded for user, please retry later","type":"rate_limit_error"}} [3merror.details[0m[2m=[0mBadRequest("AI provider returned 429 Too Many Requests: {\"error\":{\"message\":\"Concurrency limit exceeded for user, please retry later\",\"type\":\"rate_limit_error\"}}") [2m[3mhttp.method[0m[2m=[0mPOST [3mhttp.uri[0m[2m=[0m/api/ai/ask [3mhttp.version[0m[2m=[0mHTTP/1.1 [3mhttp.user_agent[0m[2m=[0mMozilla/5.0 (Windows NT 10.0; Microsoft Windows 10.0.26200; zh-CN) PowerShell/7.5.5 [3menvironment[0m[2m=[0mdevelopment [3mrequest_id[0m[2m=[0m160e41d4-83b3-49d9-ad6d-e26498301ab9[0m
|
||||
@@ -1,529 +0,0 @@
|
||||
Compiling proc-macro2 v1.0.106
|
||||
Compiling unicode-ident v1.0.24
|
||||
Compiling quote v1.0.45
|
||||
Compiling syn v2.0.117
|
||||
Compiling cfg-if v1.0.4
|
||||
Compiling serde_core v1.0.228
|
||||
Compiling memchr v2.8.0
|
||||
Compiling windows-link v0.2.1
|
||||
Compiling serde v1.0.228
|
||||
Compiling serde_derive v1.0.228
|
||||
Compiling windows-sys v0.61.2
|
||||
Compiling getrandom v0.3.4
|
||||
Compiling itoa v1.0.18
|
||||
Compiling autocfg v1.5.0
|
||||
Compiling once_cell v1.21.4
|
||||
Compiling jobserver v0.1.34
|
||||
Compiling find-msvc-tools v0.1.9
|
||||
Compiling shlex v1.3.0
|
||||
Compiling cc v1.2.57
|
||||
Compiling log v0.4.29
|
||||
Compiling pin-project-lite v0.2.17
|
||||
Compiling bytes v1.11.1
|
||||
Compiling stable_deref_trait v1.2.1
|
||||
Compiling version_check v0.9.5
|
||||
Compiling num-traits v0.2.19
|
||||
Compiling smallvec v1.15.1
|
||||
Compiling displaydoc v0.2.5
|
||||
Compiling synstructure v0.13.2
|
||||
Compiling zerofrom-derive v0.1.6
|
||||
Compiling zerofrom v0.1.6
|
||||
Compiling yoke-derive v0.8.1
|
||||
Compiling futures-core v0.3.32
|
||||
Compiling yoke v0.8.1
|
||||
Compiling percent-encoding v2.3.2
|
||||
Compiling zerovec-derive v0.11.2
|
||||
Compiling crossbeam-utils v0.8.21
|
||||
Compiling zerovec v0.11.5
|
||||
Compiling allocator-api2 v0.2.21
|
||||
Compiling socket2 v0.6.3
|
||||
Compiling mio v1.1.1
|
||||
Compiling tokio-macros v2.6.1
|
||||
Compiling tokio v1.50.0
|
||||
Compiling tinystr v0.8.2
|
||||
Compiling aho-corasick v1.1.4
|
||||
Compiling futures-sink v0.3.32
|
||||
Compiling tracing-core v0.1.36
|
||||
Compiling equivalent v1.0.2
|
||||
Compiling zerocopy v0.8.47
|
||||
Compiling zmij v1.0.21
|
||||
Compiling getrandom v0.2.17
|
||||
Compiling tracing-attributes v0.1.31
|
||||
Compiling zerocopy-derive v0.8.47
|
||||
Compiling serde_json v1.0.149
|
||||
Compiling zeroize v1.8.2
|
||||
Compiling tracing v0.1.44
|
||||
Compiling foldhash v0.2.0
|
||||
Compiling base64 v0.22.1
|
||||
Compiling hashbrown v0.16.1
|
||||
Compiling slab v0.4.12
|
||||
Compiling pkg-config v0.3.32
|
||||
Compiling futures-channel v0.3.32
|
||||
Compiling fnv v1.0.7
|
||||
Compiling indexmap v2.13.0
|
||||
Compiling futures-macro v0.3.32
|
||||
Compiling thiserror-impl v2.0.18
|
||||
Compiling futures-io v0.3.32
|
||||
Compiling subtle v2.6.1
|
||||
Compiling futures-task v0.3.32
|
||||
Compiling futures-util v0.3.32
|
||||
Compiling litemap v0.8.1
|
||||
Compiling writeable v0.6.2
|
||||
Compiling icu_locale_core v2.1.1
|
||||
Compiling potential_utf v0.1.4
|
||||
Compiling zerotrie v0.2.3
|
||||
Compiling num-integer v0.1.46
|
||||
Compiling icu_properties_data v2.1.2
|
||||
Compiling thiserror v2.0.18
|
||||
Compiling icu_normalizer_data v2.1.1
|
||||
Compiling icu_provider v2.1.1
|
||||
Compiling icu_collections v2.1.1
|
||||
Compiling form_urlencoded v1.2.2
|
||||
Compiling ring v0.17.14
|
||||
Compiling libc v0.2.183
|
||||
Compiling bitflags v2.11.0
|
||||
Compiling regex-syntax v0.8.10
|
||||
Compiling regex-automata v0.4.14
|
||||
Compiling scopeguard v1.2.0
|
||||
Compiling typenum v1.19.0
|
||||
Compiling lock_api v0.4.14
|
||||
Compiling icu_normalizer v2.1.1
|
||||
Compiling icu_properties v2.1.2
|
||||
Compiling num-bigint v0.4.6
|
||||
Compiling rustls-pki-types v1.14.0
|
||||
Compiling generic-array v0.14.7
|
||||
Compiling ryu v1.0.23
|
||||
Compiling untrusted v0.9.0
|
||||
Compiling strsim v0.11.1
|
||||
Compiling idna_adapter v1.2.1
|
||||
Compiling crossbeam-epoch v0.9.18
|
||||
Compiling utf8_iter v1.0.4
|
||||
Compiling idna v1.1.0
|
||||
Compiling ppv-lite86 v0.2.21
|
||||
Compiling chrono v0.4.44
|
||||
Compiling either v1.15.0
|
||||
Compiling rustls v0.23.37
|
||||
Compiling url v2.5.8
|
||||
Compiling crossbeam-deque v0.8.6
|
||||
Compiling rustls-webpki v0.103.10
|
||||
Compiling arrayvec v0.7.6
|
||||
Compiling libm v0.2.16
|
||||
Compiling crypto-common v0.1.7
|
||||
Compiling block-buffer v0.10.4
|
||||
Compiling webpki-roots v1.0.6
|
||||
Compiling http v1.4.0
|
||||
Compiling getrandom v0.4.2
|
||||
Compiling num-conv v0.2.1
|
||||
Compiling ident_case v1.0.1
|
||||
Compiling powerfmt v0.2.0
|
||||
Compiling windows_x86_64_msvc v0.52.6
|
||||
Compiling time-core v0.1.8
|
||||
Compiling rand_core v0.10.0
|
||||
Compiling simd-adler32 v0.3.9
|
||||
Compiling time-macros v0.2.27
|
||||
Compiling deranged v0.5.8
|
||||
Compiling darling_core v0.20.11
|
||||
Compiling digest v0.10.7
|
||||
Compiling cpufeatures v0.3.0
|
||||
Compiling byteorder v1.5.0
|
||||
Compiling chacha20 v0.10.0
|
||||
Compiling darling_macro v0.20.11
|
||||
Compiling time v0.3.47
|
||||
Compiling regex v1.12.3
|
||||
Compiling rand_core v0.6.4
|
||||
Compiling tokio-util v0.7.18
|
||||
Compiling crc32fast v1.5.0
|
||||
Compiling parking_lot_core v0.9.12
|
||||
Compiling adler2 v2.0.1
|
||||
Compiling siphasher v1.0.2
|
||||
Compiling miniz_oxide v0.8.9
|
||||
Compiling windows-targets v0.52.6
|
||||
Compiling darling v0.20.11
|
||||
Compiling rand v0.10.0
|
||||
Compiling http-body v1.0.1
|
||||
Compiling spin v0.9.8
|
||||
Compiling heck v0.4.1
|
||||
Compiling httparse v1.10.1
|
||||
Compiling tower-service v0.3.3
|
||||
Compiling uuid v1.23.0
|
||||
Compiling serde_urlencoded v0.7.1
|
||||
Compiling zstd-sys v2.0.16+zstd.1.5.7
|
||||
Compiling httpdate v1.0.3
|
||||
Compiling flate2 v1.1.9
|
||||
Compiling phf_shared v0.11.3
|
||||
Compiling rand_chacha v0.3.1
|
||||
Compiling webpki-roots v0.26.11
|
||||
Compiling bigdecimal v0.4.10
|
||||
Compiling windows_x86_64_msvc v0.48.5
|
||||
Compiling proc-macro-hack v0.5.20+deprecated
|
||||
Compiling atomic-waker v1.1.2
|
||||
Compiling windows_x86_64_msvc v0.53.1
|
||||
Compiling rust_decimal v1.40.0
|
||||
Compiling try-lock v0.2.5
|
||||
Compiling mime v0.3.17
|
||||
Compiling lazy_static v1.5.0
|
||||
Compiling want v0.3.1
|
||||
Compiling h2 v0.4.13
|
||||
Compiling rand v0.8.5
|
||||
Compiling parking_lot v0.12.5
|
||||
Compiling windows-strings v0.5.1
|
||||
Compiling windows-result v0.4.1
|
||||
Compiling bstr v1.12.1
|
||||
Compiling tower-layer v0.3.3
|
||||
Compiling pin-utils v0.1.0
|
||||
Compiling zstd-safe v7.2.4
|
||||
Compiling alloc-no-stdlib v2.0.4
|
||||
Compiling cpufeatures v0.2.17
|
||||
Compiling rayon-core v1.13.0
|
||||
Compiling foldhash v0.1.5
|
||||
Compiling hashbrown v0.15.5
|
||||
Compiling alloc-stdlib v0.2.2
|
||||
Compiling hyper v1.8.1
|
||||
Compiling windows-registry v0.6.1
|
||||
Compiling unic-langid-impl v0.9.6
|
||||
Compiling phf_generator v0.11.3
|
||||
Compiling http-body-util v0.1.3
|
||||
Compiling windows-sys v0.59.0
|
||||
Compiling concurrent-queue v2.5.0
|
||||
Compiling sync_wrapper v1.0.2
|
||||
Compiling winapi-util v0.1.11
|
||||
Compiling parking v2.2.1
|
||||
Compiling native-tls v0.2.18
|
||||
Compiling tinyvec_macros v0.1.1
|
||||
Compiling object v0.37.3
|
||||
Compiling anyhow v1.0.102
|
||||
Compiling vcpkg v0.2.15
|
||||
Compiling winapi v0.3.9
|
||||
Compiling ipnet v2.12.0
|
||||
Compiling crc-catalog v2.4.0
|
||||
Compiling crc v3.4.0
|
||||
Compiling hyper-util v0.1.20
|
||||
Compiling libsqlite3-sys v0.30.1
|
||||
Compiling tinyvec v1.11.0
|
||||
Compiling event-listener v5.4.1
|
||||
Compiling same-file v1.0.6
|
||||
Compiling parse-zoneinfo v0.3.1
|
||||
Compiling windows-targets v0.53.5
|
||||
Compiling unic-langid-macros-impl v0.9.6
|
||||
Compiling windows-targets v0.48.5
|
||||
Compiling phf_codegen v0.11.3
|
||||
Compiling brotli-decompressor v5.0.0
|
||||
Compiling hashlink v0.10.0
|
||||
Compiling sha2 v0.10.9
|
||||
Compiling futures-intrusive v0.5.0
|
||||
Compiling phf v0.11.3
|
||||
Compiling tokio-stream v0.1.18
|
||||
Compiling crossbeam-queue v0.3.12
|
||||
Compiling ahash v0.8.12
|
||||
Compiling schannel v0.1.29
|
||||
Compiling unicase v2.9.0
|
||||
Compiling ucd-trie v0.1.7
|
||||
Compiling heck v0.5.0
|
||||
Compiling pest v2.8.6
|
||||
Compiling mime_guess v2.0.5
|
||||
Compiling sqlx-core v0.8.6
|
||||
Compiling chrono-tz-build v0.3.0
|
||||
Compiling brotli v8.0.2
|
||||
Compiling windows-sys v0.48.0
|
||||
Compiling windows-sys v0.60.2
|
||||
Compiling zstd v0.13.3
|
||||
Compiling walkdir v2.5.0
|
||||
Compiling unicode-normalization v0.1.25
|
||||
Compiling tower v0.5.3
|
||||
Compiling flume v0.11.1
|
||||
Compiling hmac v0.12.1
|
||||
Compiling md-5 v0.10.6
|
||||
Compiling atoi v2.0.0
|
||||
Compiling home v0.5.12
|
||||
Compiling encoding_rs v0.8.35
|
||||
Compiling equator-macro v0.4.2
|
||||
Compiling proc-macro-error-attr2 v2.0.0
|
||||
Compiling rustversion v1.0.22
|
||||
Compiling compression-core v0.4.31
|
||||
Compiling utf8parse v0.2.2
|
||||
Compiling anstyle v1.0.14
|
||||
Compiling unicode-bidi v0.3.18
|
||||
Compiling unicode-segmentation v1.13.2
|
||||
Compiling unicode-properties v0.1.4
|
||||
Compiling once_cell_polyfill v1.70.2
|
||||
Compiling dotenvy v0.15.7
|
||||
Compiling anstyle-wincon v3.0.11
|
||||
Compiling stringprep v0.1.5
|
||||
Compiling anstyle-parse v1.0.0
|
||||
Compiling compression-codecs v0.4.37
|
||||
Compiling proc-macro-error2 v2.0.1
|
||||
Compiling equator v0.4.2
|
||||
Compiling etcetera v0.8.0
|
||||
Compiling hkdf v0.12.4
|
||||
Compiling socks v0.3.4
|
||||
Compiling ar_archive_writer v0.5.1
|
||||
Compiling chrono-tz v0.9.0
|
||||
Compiling pest_meta v2.8.6
|
||||
Compiling rayon v1.11.0
|
||||
Compiling globset v0.4.18
|
||||
Compiling tokio-rustls v0.26.4
|
||||
Compiling futures-executor v0.3.32
|
||||
Compiling proc-macro2-diagnostics v0.10.1
|
||||
Compiling rand_core v0.9.5
|
||||
Compiling anstyle-query v1.1.5
|
||||
Compiling nom v8.0.0
|
||||
Compiling colorchoice v1.0.5
|
||||
Compiling whoami v1.6.1
|
||||
Compiling is_terminal_polyfill v1.70.2
|
||||
Compiling hex v0.4.3
|
||||
Compiling base64ct v1.8.3
|
||||
Compiling paste v1.0.15
|
||||
Compiling portable-atomic v1.13.1
|
||||
Compiling static_assertions v1.1.0
|
||||
Compiling minimal-lexical v0.2.1
|
||||
Compiling nom v7.1.3
|
||||
Compiling pem-rfc7468 v1.0.0
|
||||
Compiling sqlx-postgres v0.8.6
|
||||
Compiling anstream v1.0.0
|
||||
Compiling rand_chacha v0.9.0
|
||||
Compiling sqlx-sqlite v0.8.6
|
||||
Compiling ignore v0.4.25
|
||||
Compiling sea-query-derive v0.4.3
|
||||
Compiling pest_generator v2.8.6
|
||||
Compiling psm v0.1.30
|
||||
Compiling aligned-vec v0.6.4
|
||||
Compiling async-compression v0.4.41
|
||||
Compiling tokio-native-tls v0.3.1
|
||||
Compiling ordered-float v4.6.0
|
||||
Compiling inherent v1.0.13
|
||||
Compiling num-derive v0.4.2
|
||||
Compiling clap_lex v1.1.0
|
||||
Compiling http-range-header v0.4.2
|
||||
Compiling deunicode v1.6.2
|
||||
Compiling yansi v1.0.1
|
||||
Compiling iri-string v0.7.11
|
||||
Compiling thiserror v1.0.69
|
||||
Compiling tower-http v0.6.8
|
||||
Compiling slug v0.1.6
|
||||
Compiling clap_builder v4.6.0
|
||||
Compiling sea-query v0.32.7
|
||||
Compiling ureq-proto v0.6.0
|
||||
Compiling unic-langid-macros v0.9.6
|
||||
Compiling webpki-root-certs v1.0.6
|
||||
Compiling hyper-tls v0.6.0
|
||||
Compiling v_frame v0.3.9
|
||||
Compiling pest_derive v2.8.6
|
||||
Compiling globwalk v0.9.1
|
||||
Compiling sqlx v0.8.6
|
||||
Compiling rand v0.9.2
|
||||
Compiling der v0.8.0
|
||||
Compiling hyper-rustls v0.27.7
|
||||
Compiling clap_derive v4.6.0
|
||||
Compiling sharded-slab v0.1.7
|
||||
Compiling humansize v2.1.3
|
||||
Compiling itertools v0.14.0
|
||||
Compiling num-rational v0.4.2
|
||||
Compiling matchers v0.2.0
|
||||
Compiling tracing-serde v0.2.0
|
||||
Compiling tracing-log v0.2.0
|
||||
Compiling multer v3.1.0
|
||||
Compiling as-slice v0.2.1
|
||||
Compiling stacker v0.1.23
|
||||
Compiling fs-err v2.11.0
|
||||
Compiling nu-ansi-term v0.50.3
|
||||
Compiling thread_local v1.1.9
|
||||
Compiling thiserror-impl v1.0.69
|
||||
Compiling av-scenechange v0.14.1
|
||||
Compiling utf8-zero v0.8.1
|
||||
Compiling glob v0.3.3
|
||||
Compiling built v0.8.0
|
||||
Compiling unicode-xid v0.2.6
|
||||
Compiling derive_more-impl v2.1.1
|
||||
Compiling rav1e v0.8.1
|
||||
Compiling ureq v3.3.0
|
||||
Compiling tracing-subscriber v0.3.23
|
||||
Compiling aligned v0.4.3
|
||||
Compiling tera v1.20.1
|
||||
Compiling clap v4.6.0
|
||||
Compiling reqwest v0.12.28
|
||||
Compiling sea-query-binder v0.7.0
|
||||
Compiling unic-langid v0.9.6
|
||||
Compiling ouroboros_macro v0.18.5
|
||||
Compiling hashbrown v0.14.5
|
||||
Compiling sea-bae v0.2.1
|
||||
Compiling shared_child v1.1.1
|
||||
Compiling futures v0.3.32
|
||||
Compiling onig_sys v69.9.1
|
||||
Compiling cookie v0.18.1
|
||||
Compiling matrixmultiply v0.3.10
|
||||
Compiling os_pipe v1.2.3
|
||||
Compiling core2 v0.4.0
|
||||
Compiling profiling-procmacros v1.0.17
|
||||
Compiling arg_enum_proc_macro v0.3.4
|
||||
Compiling async-trait v0.1.89
|
||||
Compiling async-stream-impl v0.3.6
|
||||
Compiling aliasable v0.1.3
|
||||
Compiling unsafe-libyaml v0.2.11
|
||||
Compiling lzma-rust2 v0.15.7
|
||||
Compiling y4m v0.8.0
|
||||
Compiling hmac-sha256 v1.1.14
|
||||
Compiling quick-error v2.0.1
|
||||
Compiling pastey v0.1.1
|
||||
Compiling fastrand v2.3.0
|
||||
Compiling shared_thread v0.2.0
|
||||
Compiling duct v1.1.1
|
||||
Compiling ort-sys v2.0.0-rc.11
|
||||
Compiling serde_yaml v0.9.34+deprecated
|
||||
Compiling ouroboros v0.18.5
|
||||
Compiling async-stream v0.3.6
|
||||
Compiling profiling v1.0.17
|
||||
Compiling bitstream-io v4.9.0
|
||||
Compiling sea-orm-macros v1.1.19
|
||||
Compiling derive_more v2.1.1
|
||||
Compiling av1-grain v0.2.5
|
||||
Compiling maybe-rayon v0.1.1
|
||||
Compiling axum-core v0.5.6
|
||||
Compiling sea-schema-derive v0.3.0
|
||||
Compiling derive_builder_core v0.20.2
|
||||
Compiling windows-sys v0.52.0
|
||||
Compiling serde_regex v1.1.0
|
||||
Compiling cruet v0.13.3
|
||||
Compiling half v2.7.1
|
||||
Compiling crossbeam-channel v0.5.15
|
||||
Compiling serde_path_to_error v0.1.20
|
||||
Compiling toml_datetime v0.6.11
|
||||
Compiling serde_spanned v0.6.9
|
||||
Compiling fax_derive v0.2.0
|
||||
Compiling axum-macros v0.5.0
|
||||
Compiling include_dir_macros v0.7.4
|
||||
Compiling simd_helpers v0.1.0
|
||||
Compiling noop_proc_macro v0.3.0
|
||||
Compiling new_debug_unreachable v1.0.6
|
||||
Compiling matchit v0.8.4
|
||||
Compiling toml_write v0.1.2
|
||||
Compiling esaxx-rs v0.1.10
|
||||
Compiling rustc-hash v2.1.1
|
||||
Compiling winnow v0.7.15
|
||||
Compiling strum v0.26.3
|
||||
Compiling zune-core v0.5.1
|
||||
Compiling imgref v1.12.0
|
||||
Compiling unicode-width v0.2.2
|
||||
Compiling option-ext v0.2.0
|
||||
Compiling rawpointer v0.2.1
|
||||
Compiling tokio-cron-scheduler v0.11.1
|
||||
Compiling encode_unicode v1.0.0
|
||||
Compiling weezl v0.1.12
|
||||
Compiling console v0.15.11
|
||||
Compiling password-hash v0.5.0
|
||||
Compiling dirs-sys v0.5.0
|
||||
Compiling loop9 v0.1.5
|
||||
Compiling zune-jpeg v0.5.15
|
||||
Compiling sea-orm v1.1.19
|
||||
Compiling toml_edit v0.22.27
|
||||
Compiling type-map v0.5.1
|
||||
Compiling axum v0.8.8
|
||||
Compiling include_dir v0.7.4
|
||||
Compiling fax v0.2.6
|
||||
Compiling rrgen v0.5.6
|
||||
Compiling socket2 v0.5.10
|
||||
Compiling derive_builder_macro v0.20.2
|
||||
Compiling sea-schema v0.16.2
|
||||
Compiling chumsky v0.9.3
|
||||
Compiling backon v1.6.0
|
||||
Compiling sea-orm-cli v1.1.19
|
||||
Compiling castaway v0.2.4
|
||||
Compiling cron v0.12.1
|
||||
Compiling validator_derive v0.20.0
|
||||
Compiling colored v2.2.0
|
||||
Compiling combine v4.6.7
|
||||
Compiling cruet v0.14.0
|
||||
Compiling simple_asn1 v0.6.4
|
||||
Compiling blake2 v0.10.6
|
||||
Compiling zune-inflate v0.2.54
|
||||
Compiling fdeflate v0.3.7
|
||||
Compiling avif-serialize v0.8.8
|
||||
Compiling serde_html_form v0.2.8
|
||||
Compiling notify-types v2.1.0
|
||||
Compiling pem v3.0.6
|
||||
Compiling email-encoding v0.4.1
|
||||
Compiling num-complex v0.4.6
|
||||
Compiling colored v3.1.1
|
||||
Compiling quick-xml v0.38.4
|
||||
Compiling hostname v0.4.2
|
||||
Compiling monostate-impl v0.1.18
|
||||
Compiling utf8-width v0.1.8
|
||||
Compiling byteorder-lite v0.1.0
|
||||
Compiling quoted_printable v0.5.2
|
||||
Compiling base64 v0.13.1
|
||||
Compiling color_quant v1.1.0
|
||||
Compiling pxfm v0.1.28
|
||||
Compiling tagptr v0.2.0
|
||||
Compiling loco-rs v0.16.4
|
||||
Compiling macro_rules_attribute-proc_macro v0.2.2
|
||||
Compiling sha1_smol v1.0.1
|
||||
Compiling rgb v0.8.53
|
||||
Compiling bytemuck v1.25.0
|
||||
Compiling email_address v0.2.9
|
||||
Compiling bit_field v0.10.3
|
||||
Compiling btparse-stable v0.1.2
|
||||
Compiling lebe v0.5.3
|
||||
Compiling number_prefix v0.4.0
|
||||
Compiling indicatif v0.17.11
|
||||
Compiling exr v1.74.0
|
||||
Compiling backtrace_printer v1.3.0
|
||||
Compiling lettre v0.11.19
|
||||
Compiling qoi v0.4.1
|
||||
Compiling ravif v0.13.0
|
||||
Compiling redis v0.31.0
|
||||
Compiling macro_rules_attribute v0.2.2
|
||||
Compiling moka v0.12.15
|
||||
Compiling moxcms v0.8.1
|
||||
Compiling gif v0.14.1
|
||||
Compiling spm_precompiled v0.1.4
|
||||
Compiling image-webp v0.2.4
|
||||
Compiling byte-unit v4.0.19
|
||||
Compiling monostate v0.1.18
|
||||
Compiling opendal v0.54.1
|
||||
Compiling loco-gen v0.16.4
|
||||
Compiling ndarray v0.17.2
|
||||
Compiling jsonwebtoken v9.3.1
|
||||
Compiling notify v8.2.0
|
||||
Compiling axum-extra v0.10.3
|
||||
Compiling png v0.18.1
|
||||
Compiling argon2 v0.5.3
|
||||
Compiling validator v0.20.0
|
||||
Compiling compact_str v0.9.0
|
||||
Compiling sea-orm-migration v1.1.19
|
||||
Compiling onig v6.5.1
|
||||
Compiling derive_builder v0.20.2
|
||||
Compiling tiff v0.11.3
|
||||
Compiling intl-memoizer v0.5.3
|
||||
Compiling toml v0.8.23
|
||||
Compiling dirs v6.0.0
|
||||
Compiling tracing-appender v0.2.4
|
||||
Compiling duct_sh v1.0.0
|
||||
Compiling dashmap v6.1.0
|
||||
Compiling intl_pluralrules v7.0.2
|
||||
Compiling fluent-langneg v0.13.1
|
||||
Compiling rayon-cond v0.4.0
|
||||
Compiling ulid v1.2.1
|
||||
Compiling ureq v2.12.1
|
||||
Compiling tower v0.4.13
|
||||
Compiling english-to-cron v0.1.7
|
||||
Compiling fluent-syntax v0.12.0
|
||||
Compiling unicode-normalization-alignments v0.1.12
|
||||
Compiling ipnetwork v0.20.0
|
||||
Compiling serde_variant v0.1.3
|
||||
Compiling dary_heap v0.3.8
|
||||
Compiling unicode_categories v0.1.1
|
||||
Compiling self_cell v1.2.2
|
||||
Compiling semver v1.0.27
|
||||
Compiling fluent-bundle v0.16.0
|
||||
Compiling tokenizers v0.22.2
|
||||
Compiling fluent-template-macros v0.13.3
|
||||
Compiling hf-hub v0.4.3
|
||||
Compiling image v0.25.10
|
||||
Compiling ort v2.0.0-rc.11
|
||||
Compiling safetensors v0.7.0
|
||||
Compiling fastembed v5.13.0
|
||||
Compiling fluent-templates v0.13.3
|
||||
Compiling migration v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend\migration)
|
||||
Compiling termi-api v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend)
|
||||
Finished `dev` profile [unoptimized] target(s) in 8m 53s
|
||||
Running `target\debug\termi_api-cli.exe start`
|
||||
error: process didn't exit successfully: `target\debug\termi_api-cli.exe start` (exit code: 1073807364)
|
||||
@@ -1,25 +0,0 @@
|
||||
[2m2026-03-28T15:13:51.613322Z[0m [33m WARN[0m [2mloco_rs::boot[0m[2m:[0m pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml)
|
||||
|
||||
▄ ▀
|
||||
▀ ▄
|
||||
▄ ▀ ▄ ▄ ▄▀
|
||||
▄ ▀▄▄
|
||||
▄ ▀ ▀ ▀▄▀█▄
|
||||
▀█▄
|
||||
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
|
||||
██████ █████ ███ █████ ███ █████ ███ ▀█
|
||||
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
||||
██████ █████ ███ █████ █████ ███ ████▄
|
||||
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
||||
██████ █████ ███ ████ ███ █████ ███ ████▀
|
||||
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
https://loco.rs
|
||||
|
||||
environment: development
|
||||
database: automigrate
|
||||
logger: debug
|
||||
compilation: debug
|
||||
modes: server
|
||||
|
||||
listening on http://localhost:5150
|
||||
@@ -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:
|
||||
|
||||
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 "$@"
|
||||
@@ -25,6 +25,17 @@ 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]
|
||||
@@ -54,6 +65,17 @@ impl MigratorTrait for Migrator {
|
||||
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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
const TABLE: &str = "comments";
|
||||
const IP_ADDRESS_COLUMN: &str = "ip_address";
|
||||
const USER_AGENT_COLUMN: &str = "user_agent";
|
||||
const REFERER_COLUMN: &str = "referer";
|
||||
const COMMENT_IP_INDEX: &str = "idx_comments_ip_address";
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new(TABLE);
|
||||
|
||||
if !manager.has_column(TABLE, IP_ADDRESS_COLUMN).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new(IP_ADDRESS_COLUMN))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column(TABLE, USER_AGENT_COLUMN).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(ColumnDef::new(Alias::new(USER_AGENT_COLUMN)).text().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column(TABLE, REFERER_COLUMN).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table)
|
||||
.add_column(ColumnDef::new(Alias::new(REFERER_COLUMN)).text().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(&format!(
|
||||
"CREATE INDEX IF NOT EXISTS {COMMENT_IP_INDEX} ON {TABLE} ({IP_ADDRESS_COLUMN})"
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(&format!("DROP INDEX IF EXISTS {COMMENT_IP_INDEX}"))
|
||||
.await?;
|
||||
|
||||
for column in [REFERER_COLUMN, USER_AGENT_COLUMN, IP_ADDRESS_COLUMN] {
|
||||
if manager.has_column(TABLE, column).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new(TABLE))
|
||||
.drop_column(Alias::new(column))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("comment_blacklist"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("matcher_type"))
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("matcher_value"))
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("reason")).text().null())
|
||||
.col(ColumnDef::new(Alias::new("active")).boolean().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("expires_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_comment_blacklist_matcher")
|
||||
.table(Alias::new("comment_blacklist"))
|
||||
.col(Alias::new("matcher_type"))
|
||||
.col(Alias::new("matcher_value"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_comment_blacklist_active_expires")
|
||||
.table(Alias::new("comment_blacklist"))
|
||||
.col(Alias::new("active"))
|
||||
.col(Alias::new("expires_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_comment_blacklist_active_expires",
|
||||
"idx_comment_blacklist_matcher",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("comment_blacklist"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("comment_blacklist"))
|
||||
.if_exists()
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("comment_persona_analysis_logs"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("matcher_type"))
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("matcher_value"))
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("from_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("to_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("total_comments"))
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("pending_comments"))
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("distinct_posts"))
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("analysis_text"))
|
||||
.text()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("sample_json"))
|
||||
.json_binary()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_comment_persona_analysis_logs_matcher_created_at")
|
||||
.table(Alias::new("comment_persona_analysis_logs"))
|
||||
.col(Alias::new("matcher_type"))
|
||||
.col(Alias::new("matcher_value"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_comment_persona_analysis_logs_created_at")
|
||||
.table(Alias::new("comment_persona_analysis_logs"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_comment_persona_analysis_logs_created_at",
|
||||
"idx_comment_persona_analysis_logs_matcher_created_at",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("comment_persona_analysis_logs"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(Alias::new("comment_persona_analysis_logs"))
|
||||
.if_exists()
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("posts");
|
||||
|
||||
if !manager.has_column("posts", "status").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("status"))
|
||||
.string()
|
||||
.null()
|
||||
.default("published"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "visibility").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("visibility"))
|
||||
.string()
|
||||
.null()
|
||||
.default("public"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "publish_at").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("publish_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "unpublish_at").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("unpublish_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "canonical_url").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(ColumnDef::new(Alias::new("canonical_url")).text().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "noindex").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("noindex"))
|
||||
.boolean()
|
||||
.null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "og_image").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(ColumnDef::new(Alias::new("og_image")).text().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "redirect_from").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("redirect_from"))
|
||||
.json_binary()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager.has_column("posts", "redirect_to").await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(ColumnDef::new(Alias::new("redirect_to")).text().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("posts");
|
||||
|
||||
for column in [
|
||||
"redirect_to",
|
||||
"redirect_from",
|
||||
"og_image",
|
||||
"noindex",
|
||||
"canonical_url",
|
||||
"unpublish_at",
|
||||
"publish_at",
|
||||
"visibility",
|
||||
"status",
|
||||
] {
|
||||
if manager.has_column("posts", column).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.drop_column(Alias::new(column))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("site_settings");
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "seo_default_og_image")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("seo_default_og_image"))
|
||||
.text()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "seo_default_twitter_handle")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("seo_default_twitter_handle"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "notification_webhook_url")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("notification_webhook_url"))
|
||||
.text()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "notification_comment_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("notification_comment_enabled"))
|
||||
.boolean()
|
||||
.null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "notification_friend_link_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("notification_friend_link_enabled"))
|
||||
.boolean()
|
||||
.null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !manager
|
||||
.has_column("site_settings", "search_synonyms")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("search_synonyms"))
|
||||
.json_binary()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("site_settings");
|
||||
|
||||
for column in [
|
||||
"search_synonyms",
|
||||
"notification_friend_link_enabled",
|
||||
"notification_comment_enabled",
|
||||
"notification_webhook_url",
|
||||
"seo_default_twitter_handle",
|
||||
"seo_default_og_image",
|
||||
] {
|
||||
if manager.has_column("site_settings", column).await? {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.drop_column(Alias::new(column))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_table(
|
||||
manager,
|
||||
"content_events",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("event_type", ColType::String),
|
||||
("path", ColType::String),
|
||||
("post_slug", ColType::StringNull),
|
||||
("session_id", ColType::StringNull),
|
||||
("referrer", ColType::StringNull),
|
||||
("user_agent", ColType::TextNull),
|
||||
("duration_ms", ColType::IntegerNull),
|
||||
("progress_percent", ColType::IntegerNull),
|
||||
("metadata", ColType::JsonBinaryNull),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_content_events_event_type_created_at")
|
||||
.table(Alias::new("content_events"))
|
||||
.col(Alias::new("event_type"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_content_events_post_slug_created_at")
|
||||
.table(Alias::new("content_events"))
|
||||
.col(Alias::new("post_slug"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_content_events_referrer")
|
||||
.table(Alias::new("content_events"))
|
||||
.col(Alias::new("referrer"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_content_events_referrer",
|
||||
"idx_content_events_post_slug_created_at",
|
||||
"idx_content_events_event_type_created_at",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("content_events"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
drop_table(manager, "content_events").await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_table(
|
||||
manager,
|
||||
"admin_audit_logs",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("actor_username", ColType::StringNull),
|
||||
("actor_email", ColType::StringNull),
|
||||
("actor_source", ColType::StringNull),
|
||||
("action", ColType::String),
|
||||
("target_type", ColType::String),
|
||||
("target_id", ColType::StringNull),
|
||||
("target_label", ColType::StringNull),
|
||||
("metadata", ColType::JsonBinaryNull),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_admin_audit_logs_action_created_at")
|
||||
.table(Alias::new("admin_audit_logs"))
|
||||
.col(Alias::new("action"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_admin_audit_logs_target_type_created_at")
|
||||
.table(Alias::new("admin_audit_logs"))
|
||||
.col(Alias::new("target_type"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_admin_audit_logs_target_type_created_at",
|
||||
"idx_admin_audit_logs_action_created_at",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("admin_audit_logs"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
drop_table(manager, "admin_audit_logs").await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_table(
|
||||
manager,
|
||||
"post_revisions",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("post_slug", ColType::String),
|
||||
("post_title", ColType::StringNull),
|
||||
("operation", ColType::String),
|
||||
("revision_reason", ColType::TextNull),
|
||||
("actor_username", ColType::StringNull),
|
||||
("actor_email", ColType::StringNull),
|
||||
("actor_source", ColType::StringNull),
|
||||
("markdown", ColType::TextNull),
|
||||
("metadata", ColType::JsonBinaryNull),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_post_revisions_post_slug_created_at")
|
||||
.table(Alias::new("post_revisions"))
|
||||
.col(Alias::new("post_slug"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_post_revisions_operation_created_at")
|
||||
.table(Alias::new("post_revisions"))
|
||||
.col(Alias::new("operation"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_post_revisions_operation_created_at",
|
||||
"idx_post_revisions_post_slug_created_at",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("post_revisions"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
drop_table(manager, "post_revisions").await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_table(
|
||||
manager,
|
||||
"subscriptions",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("channel_type", ColType::String),
|
||||
("target", ColType::String),
|
||||
("display_name", ColType::StringNull),
|
||||
("status", ColType::String),
|
||||
("filters", ColType::JsonBinaryNull),
|
||||
("secret", ColType::TextNull),
|
||||
("notes", ColType::TextNull),
|
||||
("verified_at", ColType::StringNull),
|
||||
("last_notified_at", ColType::StringNull),
|
||||
("failure_count", ColType::IntegerNull),
|
||||
("last_delivery_status", ColType::StringNull),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_subscriptions_channel_status")
|
||||
.table(Alias::new("subscriptions"))
|
||||
.col(Alias::new("channel_type"))
|
||||
.col(Alias::new("status"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_subscriptions_channel_target_unique")
|
||||
.table(Alias::new("subscriptions"))
|
||||
.col(Alias::new("channel_type"))
|
||||
.col(Alias::new("target"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_subscriptions_channel_target_unique",
|
||||
"idx_subscriptions_channel_status",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("subscriptions"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
drop_table(manager, "subscriptions").await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_table(
|
||||
manager,
|
||||
"notification_deliveries",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("subscription_id", ColType::IntegerNull),
|
||||
("channel_type", ColType::String),
|
||||
("target", ColType::String),
|
||||
("event_type", ColType::String),
|
||||
("status", ColType::String),
|
||||
("provider", ColType::StringNull),
|
||||
("response_text", ColType::TextNull),
|
||||
("payload", ColType::JsonBinaryNull),
|
||||
("delivered_at", ColType::StringNull),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_notification_deliveries_event_created_at")
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.col(Alias::new("event_type"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_notification_deliveries_subscription_created_at")
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.col(Alias::new("subscription_id"))
|
||||
.col(Alias::new("created_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
for index_name in [
|
||||
"idx_notification_deliveries_subscription_created_at",
|
||||
"idx_notification_deliveries_event_created_at",
|
||||
] {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name(index_name)
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
drop_table(manager, "notification_deliveries").await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("subscriptions"))
|
||||
.add_column_if_not_exists(
|
||||
ColumnDef::new(Alias::new("confirm_token"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.add_column_if_not_exists(
|
||||
ColumnDef::new(Alias::new("manage_token"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.add_column_if_not_exists(
|
||||
ColumnDef::new(Alias::new("metadata"))
|
||||
.json_binary()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_subscriptions_confirm_token_unique")
|
||||
.table(Alias::new("subscriptions"))
|
||||
.col(Alias::new("confirm_token"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_subscriptions_manage_token_unique")
|
||||
.table(Alias::new("subscriptions"))
|
||||
.col(Alias::new("manage_token"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.add_column_if_not_exists(
|
||||
ColumnDef::new(Alias::new("attempts_count"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.add_column_if_not_exists(
|
||||
ColumnDef::new(Alias::new("next_retry_at"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.add_column_if_not_exists(
|
||||
ColumnDef::new(Alias::new("last_attempt_at"))
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_notification_deliveries_status_next_retry")
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.col(Alias::new("status"))
|
||||
.col(Alias::new("next_retry_at"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_notification_deliveries_status_next_retry")
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("notification_deliveries"))
|
||||
.drop_column(Alias::new("last_attempt_at"))
|
||||
.drop_column(Alias::new("next_retry_at"))
|
||||
.drop_column(Alias::new("attempts_count"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_subscriptions_manage_token_unique")
|
||||
.table(Alias::new("subscriptions"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_index(
|
||||
Index::drop()
|
||||
.name("idx_subscriptions_confirm_token_unique")
|
||||
.table(Alias::new("subscriptions"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("subscriptions"))
|
||||
.drop_column(Alias::new("metadata"))
|
||||
.drop_column(Alias::new("manage_token"))
|
||||
.drop_column(Alias::new("confirm_token"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
Compiling termi-api v0.1.0 (D:\dev\frontend\svelte\termi-astro\backend)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 17.22s
|
||||
Running `target\debug\termi_api-cli.exe start`
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
|
||||
},
|
||||
tasks,
|
||||
workers::downloader::DownloadWorker,
|
||||
workers::{downloader::DownloadWorker, notification_delivery::NotificationDeliveryWorker},
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
@@ -54,16 +54,14 @@ impl Hooks for App {
|
||||
}
|
||||
|
||||
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
|
||||
Ok(vec![
|
||||
Box::new(initializers::content_sync::ContentSyncInitializer),
|
||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||
])
|
||||
Ok(vec![Box::new(initializers::content_sync::ContentSyncInitializer)])
|
||||
}
|
||||
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::admin::routes())
|
||||
.add_route(controllers::health::routes())
|
||||
.add_route(controllers::admin_api::routes())
|
||||
.add_route(controllers::admin_ops::routes())
|
||||
.add_route(controllers::review::routes())
|
||||
.add_route(controllers::category::routes())
|
||||
.add_route(controllers::friend_link::routes())
|
||||
@@ -71,9 +69,11 @@ impl Hooks for App {
|
||||
.add_route(controllers::comment::routes())
|
||||
.add_route(controllers::post::routes())
|
||||
.add_route(controllers::search::routes())
|
||||
.add_route(controllers::content_analytics::routes())
|
||||
.add_route(controllers::site_settings::routes())
|
||||
.add_route(controllers::ai::routes())
|
||||
.add_route(controllers::auth::routes())
|
||||
.add_route(controllers::subscription::routes())
|
||||
}
|
||||
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let cors = CorsLayer::new()
|
||||
@@ -91,11 +91,15 @@ impl Hooks for App {
|
||||
}
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
queue.register(NotificationDeliveryWorker::build(ctx)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn register_tasks(tasks: &mut Tasks) {
|
||||
tasks.register(tasks::retry_deliveries::RetryDeliveries);
|
||||
tasks.register(tasks::send_weekly_digest::SendWeeklyDigest);
|
||||
tasks.register(tasks::send_monthly_digest::SendMonthlyDigest);
|
||||
// tasks-inject (do not remove)
|
||||
}
|
||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
455
backend/src/controllers/admin_ops.rs
Normal file
455
backend/src/controllers/admin_ops.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder,
|
||||
QuerySelect, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
models::_entities::{
|
||||
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
|
||||
},
|
||||
services::{admin_audit, post_revisions as revision_service, subscriptions as subscription_service},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct AuditLogQuery {
|
||||
pub action: Option<String>,
|
||||
pub target_type: Option<String>,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct RevisionQuery {
|
||||
pub slug: Option<String>,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct DeliveriesQuery {
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SubscriptionPayload {
|
||||
#[serde(alias = "channelType")]
|
||||
pub channel_type: String,
|
||||
pub target: String,
|
||||
#[serde(default, alias = "displayName")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filters: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SubscriptionUpdatePayload {
|
||||
#[serde(default, alias = "channelType")]
|
||||
pub channel_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub target: Option<String>,
|
||||
#[serde(default, alias = "displayName")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filters: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct RestoreRevisionRequest {
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct DigestDispatchRequest {
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PostRevisionListItem {
|
||||
pub id: i32,
|
||||
pub post_slug: String,
|
||||
pub post_title: Option<String>,
|
||||
pub operation: String,
|
||||
pub revision_reason: Option<String>,
|
||||
pub actor_username: Option<String>,
|
||||
pub actor_email: Option<String>,
|
||||
pub actor_source: Option<String>,
|
||||
pub created_at: String,
|
||||
pub has_markdown: bool,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PostRevisionDetailResponse {
|
||||
#[serde(flatten)]
|
||||
pub item: PostRevisionListItem,
|
||||
pub markdown: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RestoreRevisionResponse {
|
||||
pub restored: bool,
|
||||
pub revision_id: i32,
|
||||
pub post_slug: String,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SubscriptionListResponse {
|
||||
pub subscriptions: Vec<subscriptions::Model>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DeliveryListResponse {
|
||||
pub deliveries: Vec<notification_deliveries::Model>,
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn format_revision(item: post_revisions::Model) -> PostRevisionListItem {
|
||||
PostRevisionListItem {
|
||||
id: item.id,
|
||||
post_slug: item.post_slug,
|
||||
post_title: item.post_title,
|
||||
operation: item.operation,
|
||||
revision_reason: item.revision_reason,
|
||||
actor_username: item.actor_username,
|
||||
actor_email: item.actor_email,
|
||||
actor_source: item.actor_source,
|
||||
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(),
|
||||
metadata: item.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_audit_logs(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AuditLogQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
|
||||
let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
|
||||
|
||||
if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
|
||||
db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action));
|
||||
}
|
||||
|
||||
if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
|
||||
db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type));
|
||||
}
|
||||
|
||||
format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_post_revisions(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<RevisionQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?;
|
||||
format::json(items.into_iter().map(format_revision).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_post_revision(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
let item = revision_service::get_revision(&ctx, id).await?;
|
||||
format::json(PostRevisionDetailResponse {
|
||||
item: format_revision(item.clone()),
|
||||
markdown: item.markdown,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn restore_post_revision(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<RestoreRevisionRequest>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let mode = payload.mode.unwrap_or_else(|| "full".to_string());
|
||||
let restored =
|
||||
revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.revision.restore",
|
||||
"post_revision",
|
||||
Some(restored.id.to_string()),
|
||||
Some(restored.post_slug.clone()),
|
||||
Some(serde_json::json!({
|
||||
"post_slug": restored.post_slug,
|
||||
"source_revision_id": id,
|
||||
"mode": mode,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(RestoreRevisionResponse {
|
||||
restored: true,
|
||||
revision_id: id,
|
||||
post_slug: restored.post_slug,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_subscriptions(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(SubscriptionListResponse {
|
||||
subscriptions: subscription_service::list_subscriptions(&ctx, None, None).await?,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_subscription_deliveries(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<DeliveriesQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(DeliveryListResponse {
|
||||
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn create_subscription(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<SubscriptionPayload>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
|
||||
let channel_type = subscription_service::normalize_channel_type(&payload.channel_type);
|
||||
let target = payload.target.trim().to_string();
|
||||
if target.is_empty() {
|
||||
return Err(Error::BadRequest("target 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let created = subscriptions::ActiveModel {
|
||||
channel_type: Set(channel_type.clone()),
|
||||
target: Set(target.clone()),
|
||||
display_name: Set(trim_to_option(payload.display_name)),
|
||||
status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))),
|
||||
filters: Set(subscription_service::normalize_filters(payload.filters)),
|
||||
metadata: Set(payload.metadata),
|
||||
secret: Set(trim_to_option(payload.secret)),
|
||||
notes: Set(trim_to_option(payload.notes)),
|
||||
confirm_token: Set(None),
|
||||
manage_token: Set(Some(subscription_service::generate_subscription_token())),
|
||||
verified_at: Set(Some(chrono::Utc::now().to_rfc3339())),
|
||||
failure_count: Set(Some(0)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"subscription.create",
|
||||
"subscription",
|
||||
Some(created.id.to_string()),
|
||||
Some(format!("{}:{}", created.channel_type, created.target)),
|
||||
Some(serde_json::json!({ "channel_type": created.channel_type, "target": created.target })),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(created)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_subscription(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<SubscriptionUpdatePayload>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
|
||||
let item = subscriptions::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
let mut active = item.clone().into_active_model();
|
||||
|
||||
if let Some(channel_type) = payload.channel_type {
|
||||
active.channel_type = Set(subscription_service::normalize_channel_type(&channel_type));
|
||||
}
|
||||
if let Some(target) = payload.target {
|
||||
let normalized_target = target.trim().to_string();
|
||||
if normalized_target.is_empty() {
|
||||
return Err(Error::BadRequest("target 不能为空".to_string()));
|
||||
}
|
||||
active.target = Set(normalized_target);
|
||||
}
|
||||
if payload.display_name.is_some() {
|
||||
active.display_name = Set(trim_to_option(payload.display_name));
|
||||
}
|
||||
if let Some(status) = payload.status {
|
||||
active.status = Set(subscription_service::normalize_status(&status));
|
||||
}
|
||||
if payload.filters.is_some() {
|
||||
active.filters = Set(subscription_service::normalize_filters(payload.filters));
|
||||
}
|
||||
if payload.metadata.is_some() {
|
||||
active.metadata = Set(payload.metadata);
|
||||
}
|
||||
if payload.secret.is_some() {
|
||||
active.secret = Set(trim_to_option(payload.secret));
|
||||
}
|
||||
if payload.notes.is_some() {
|
||||
active.notes = Set(trim_to_option(payload.notes));
|
||||
}
|
||||
|
||||
let updated = active.update(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"subscription.update",
|
||||
"subscription",
|
||||
Some(updated.id.to_string()),
|
||||
Some(format!("{}:{}", updated.channel_type, updated.target)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(updated)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn delete_subscription(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = subscriptions::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
let label = format!("{}:{}", item.channel_type, item.target);
|
||||
item.delete(&ctx.db).await?;
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"subscription.delete",
|
||||
"subscription",
|
||||
Some(id.to_string()),
|
||||
Some(label),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn test_subscription(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = subscriptions::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
let delivery = subscription_service::send_test_notification(&ctx, &item).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"subscription.test",
|
||||
"subscription",
|
||||
Some(item.id.to_string()),
|
||||
Some(format!("{}:{}", item.channel_type, item.target)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn send_subscription_digest(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<DigestDispatchRequest>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?;
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"subscription.digest.send",
|
||||
"subscription_digest",
|
||||
None,
|
||||
Some(summary.period.clone()),
|
||||
Some(serde_json::json!({
|
||||
"period": summary.period,
|
||||
"post_count": summary.post_count,
|
||||
"queued": summary.queued,
|
||||
"skipped": summary.skipped,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(summary)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
.add("/audit-logs", get(list_audit_logs))
|
||||
.add("/post-revisions", get(list_post_revisions))
|
||||
.add("/post-revisions/{id}", get(get_post_revision))
|
||||
.add("/post-revisions/{id}/restore", post(restore_post_revision))
|
||||
.add("/subscriptions", get(list_subscriptions).post(create_subscription))
|
||||
.add("/subscriptions/deliveries", get(list_subscription_deliveries))
|
||||
.add("/subscriptions/digest", post(send_subscription_digest))
|
||||
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
|
||||
.add("/subscriptions/{id}/test", post(test_subscription))
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
controllers::{admin::check_auth, site_settings},
|
||||
services::{ai, analytics},
|
||||
services::{abuse_guard, ai, analytics},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -212,6 +212,11 @@ pub async fn ask(
|
||||
let started_at = Instant::now();
|
||||
let question = payload.question.trim().to_string();
|
||||
let (provider, chat_model) = current_provider_metadata(&ctx).await;
|
||||
abuse_guard::enforce_public_scope(
|
||||
"ai_ask",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
Some(&question),
|
||||
)?;
|
||||
|
||||
match ai::answer_question(&ctx, &payload.question).await {
|
||||
Ok(result) => {
|
||||
@@ -263,6 +268,11 @@ pub async fn ask_stream(
|
||||
let request_headers = headers.clone();
|
||||
let question = payload.question.trim().to_string();
|
||||
let (fallback_provider, fallback_chat_model) = current_provider_metadata(&ctx).await;
|
||||
abuse_guard::enforce_public_scope(
|
||||
"ai_stream",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
Some(&question),
|
||||
)?;
|
||||
|
||||
let stream = stream! {
|
||||
let started_at = Instant::now();
|
||||
@@ -503,8 +513,8 @@ pub async fn ask_stream(
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reindex(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth()?;
|
||||
pub async fn reindex(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
let summary = ai::rebuild_index(&ctx).await?;
|
||||
|
||||
format::json(ReindexResponse {
|
||||
|
||||
@@ -5,11 +5,23 @@ use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{rejection::ExtensionRejection, ConnectInfo},
|
||||
http::{header, HeaderMap},
|
||||
};
|
||||
|
||||
use crate::models::_entities::{
|
||||
comments::{ActiveModel, Column, Entity, Model},
|
||||
posts,
|
||||
};
|
||||
use crate::services::{
|
||||
admin_audit,
|
||||
comment_guard::{self, CommentGuardInput},
|
||||
notifications,
|
||||
};
|
||||
use crate::controllers::admin::check_auth;
|
||||
|
||||
const ARTICLE_SCOPE: &str = "article";
|
||||
const PARAGRAPH_SCOPE: &str = "paragraph";
|
||||
@@ -106,6 +118,12 @@ pub struct CreateCommentRequest {
|
||||
pub paragraph_excerpt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub approved: Option<bool>,
|
||||
#[serde(default, alias = "captchaToken")]
|
||||
pub captcha_token: Option<String>,
|
||||
#[serde(default, alias = "captchaAnswer")]
|
||||
pub captcha_answer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub website: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -125,6 +143,50 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_with_limit(value: Option<&str>, max_chars: usize) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(trimmed.chars().take(max_chars).collect::<String>())
|
||||
})
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, key: header::HeaderName) -> Option<&'a str> {
|
||||
headers.get(key).and_then(|value| value.to_str().ok())
|
||||
}
|
||||
|
||||
fn first_forwarded_ip(value: &str) -> Option<&str> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn detect_client_ip(
|
||||
headers: &HeaderMap,
|
||||
connect_info: Option<&ConnectInfo<SocketAddr>>,
|
||||
) -> Option<String> {
|
||||
let forwarded = header_value(headers, header::HeaderName::from_static("x-forwarded-for"))
|
||||
.and_then(first_forwarded_ip);
|
||||
let real_ip = header_value(headers, header::HeaderName::from_static("x-real-ip"));
|
||||
let cf_connecting_ip =
|
||||
header_value(headers, header::HeaderName::from_static("cf-connecting-ip"));
|
||||
let true_client_ip = header_value(headers, header::HeaderName::from_static("true-client-ip"));
|
||||
let remote_addr = connect_info.map(|addr| addr.0.ip().to_string());
|
||||
|
||||
normalize_with_limit(
|
||||
forwarded
|
||||
.or(real_ip)
|
||||
.or(cf_connecting_ip)
|
||||
.or(true_client_ip)
|
||||
.or(remote_addr.as_deref()),
|
||||
96,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalized_scope(value: Option<String>) -> Result<String> {
|
||||
match value
|
||||
.unwrap_or_else(|| ARTICLE_SCOPE.to_string())
|
||||
@@ -171,7 +233,12 @@ async fn resolve_post_slug(ctx: &AppContext, raw: &str) -> Result<Option<String>
|
||||
pub async fn list(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
if query.approved != Some(true) {
|
||||
check_auth(&headers)?;
|
||||
}
|
||||
|
||||
let mut db_query = Entity::find().order_by_asc(Column::CreatedAt);
|
||||
|
||||
let post_slug = if let Some(post_slug) = query.post_slug {
|
||||
@@ -252,9 +319,22 @@ pub async fn paragraph_summary(
|
||||
format::json(summary)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn captcha_challenge(
|
||||
headers: HeaderMap,
|
||||
connect_info: Result<ConnectInfo<SocketAddr>, ExtensionRejection>,
|
||||
) -> Result<Response> {
|
||||
let ip_address = detect_client_ip(&headers, connect_info.as_ref().ok());
|
||||
format::json(comment_guard::create_captcha_challenge(
|
||||
ip_address.as_deref(),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
connect_info: Result<ConnectInfo<SocketAddr>, ExtensionRejection>,
|
||||
Json(params): Json<CreateCommentRequest>,
|
||||
) -> Result<Response> {
|
||||
let scope = normalized_scope(params.scope.clone())?;
|
||||
@@ -271,6 +351,9 @@ pub async fn add(
|
||||
let email = normalize_optional_string(params.email);
|
||||
let avatar = normalize_optional_string(params.avatar);
|
||||
let content = normalize_optional_string(params.content);
|
||||
let ip_address = detect_client_ip(&headers, connect_info.as_ref().ok());
|
||||
let user_agent = normalize_with_limit(header_value(&headers, header::USER_AGENT), 512);
|
||||
let referer = normalize_with_limit(header_value(&headers, header::REFERER), 1024);
|
||||
let paragraph_key = normalize_optional_string(params.paragraph_key);
|
||||
let paragraph_excerpt = normalize_optional_string(params.paragraph_excerpt)
|
||||
.or_else(|| content.as_deref().and_then(preview_excerpt));
|
||||
@@ -291,6 +374,21 @@ pub async fn add(
|
||||
return Err(Error::BadRequest("paragraph_key is required".to_string()));
|
||||
}
|
||||
|
||||
comment_guard::enforce_comment_guard(
|
||||
&ctx,
|
||||
&CommentGuardInput {
|
||||
ip_address: ip_address.as_deref(),
|
||||
email: email.as_deref(),
|
||||
user_agent: user_agent.as_deref(),
|
||||
author: author.as_deref(),
|
||||
content: content.as_deref(),
|
||||
honeypot_website: params.website.as_deref(),
|
||||
captcha_token: params.captcha_token.as_deref(),
|
||||
captcha_answer: params.captcha_answer.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
@@ -302,6 +400,9 @@ pub async fn add(
|
||||
item.author = Set(author);
|
||||
item.email = Set(email);
|
||||
item.avatar = Set(avatar);
|
||||
item.ip_address = Set(ip_address);
|
||||
item.user_agent = Set(user_agent);
|
||||
item.referer = Set(referer);
|
||||
item.content = Set(content);
|
||||
item.scope = Set(scope);
|
||||
item.paragraph_key = Set(paragraph_key);
|
||||
@@ -313,36 +414,72 @@ pub async fn add(
|
||||
item.reply_to_comment_id = Set(params.reply_to_comment_id);
|
||||
item.approved = Set(Some(params.approved.unwrap_or(false)));
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
notifications::notify_new_comment(&ctx, &item).await;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"comment.update",
|
||||
"comment",
|
||||
Some(item.id.to_string()),
|
||||
item.post_slug.clone(),
|
||||
Some(serde_json::json!({ "approved": item.approved })),
|
||||
)
|
||||
.await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
pub async fn remove(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let label = item.post_slug.clone();
|
||||
item.delete(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"comment.delete",
|
||||
"comment",
|
||||
Some(id.to_string()),
|
||||
label,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn get_one(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/comments/")
|
||||
.add("captcha", get(captcha_challenge))
|
||||
.add("/", get(list))
|
||||
.add("paragraphs/summary", get(paragraph_summary))
|
||||
.add("/", post(add))
|
||||
|
||||
68
backend/src/controllers/content_analytics.rs
Normal file
68
backend/src/controllers/content_analytics.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::services::analytics;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ContentAnalyticsEventPayload {
|
||||
pub event_type: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub post_slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration_ms: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub progress_percent: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub referrer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ContentAnalyticsEventResponse {
|
||||
pub recorded: bool,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn record(
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<ContentAnalyticsEventPayload>,
|
||||
) -> Result<Response> {
|
||||
let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers);
|
||||
if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() {
|
||||
request_context.referrer = payload.referrer;
|
||||
}
|
||||
|
||||
analytics::record_content_event(
|
||||
&ctx,
|
||||
analytics::ContentEventDraft {
|
||||
event_type: payload.event_type,
|
||||
path: payload.path,
|
||||
post_slug: payload.post_slug,
|
||||
session_id: payload.session_id,
|
||||
request_context,
|
||||
duration_ms: payload.duration_ms,
|
||||
progress_percent: payload.progress_percent,
|
||||
metadata: payload.metadata,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
format::json(ContentAnalyticsEventResponse { recorded: true })
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/analytics/")
|
||||
.add("content", post(record))
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::controllers::admin::check_auth;
|
||||
use crate::models::_entities::friend_links::{ActiveModel, Column, Entity, Model};
|
||||
use crate::services::{admin_audit, notifications};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
@@ -69,11 +72,15 @@ async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
pub async fn list(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let authenticated = check_auth(&headers).ok();
|
||||
let mut db_query = Entity::find().order_by_desc(Column::CreatedAt);
|
||||
|
||||
if let Some(status) = query.status {
|
||||
db_query = db_query.filter(Column::Status.eq(status));
|
||||
} else if authenticated.is_none() {
|
||||
db_query = db_query.filter(Column::Status.eq("approved"));
|
||||
}
|
||||
|
||||
if let Some(category) = query.category {
|
||||
@@ -98,30 +105,65 @@ pub async fn add(
|
||||
item.category = Set(params.category);
|
||||
item.status = Set(Some(params.status.unwrap_or_else(|| "pending".to_string())));
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
notifications::notify_new_friend_link(&ctx, &item).await;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"friend_link.update",
|
||||
"friend_link",
|
||||
Some(item.id.to_string()),
|
||||
item.site_name.clone().or_else(|| Some(item.site_url.clone())),
|
||||
Some(serde_json::json!({ "status": item.status })),
|
||||
)
|
||||
.await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
pub async fn remove(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let label = item.site_name.clone().or_else(|| Some(item.site_url.clone()));
|
||||
item.delete(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"friend_link.delete",
|
||||
"friend_link",
|
||||
Some(id.to_string()),
|
||||
label,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn get_one(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
}
|
||||
|
||||
|
||||
13
backend/src/controllers/health.rs
Normal file
13
backend/src/controllers/health.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn healthz() -> Result<Response> {
|
||||
format::json(serde_json::json!({
|
||||
"ok": true,
|
||||
"service": "backend",
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().add("/healthz", get(healthz))
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
pub mod admin;
|
||||
pub mod admin_api;
|
||||
pub mod admin_ops;
|
||||
pub mod ai;
|
||||
pub mod auth;
|
||||
pub mod content_analytics;
|
||||
pub mod category;
|
||||
pub mod comment;
|
||||
pub mod friend_link;
|
||||
pub mod health;
|
||||
pub mod post;
|
||||
pub mod review;
|
||||
pub mod search;
|
||||
pub mod site_settings;
|
||||
pub mod subscription;
|
||||
pub mod tag;
|
||||
|
||||
@@ -1,13 +1,312 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use axum::extract::Multipart;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum::{extract::Multipart, http::HeaderMap};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model};
|
||||
use crate::services::content;
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
services::{admin_audit, content, post_revisions, subscriptions},
|
||||
};
|
||||
|
||||
fn deserialize_boolish_option<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = Option::<String>::deserialize(deserializer)?;
|
||||
|
||||
raw.map(|value| match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"invalid boolean value `{other}`"
|
||||
))),
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn normalize_slug_key(value: &str) -> String {
|
||||
value.trim().trim_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn request_preview_mode(preview: Option<bool>, headers: &HeaderMap) -> bool {
|
||||
preview.unwrap_or(false)
|
||||
|| headers
|
||||
.get("x-termi-post-mode")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.eq_ignore_ascii_case("preview"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn requested_status(status: Option<String>, published: Option<bool>) -> String {
|
||||
if let Some(status) = status.as_deref() {
|
||||
return content::normalize_post_status(Some(status));
|
||||
}
|
||||
|
||||
if published == Some(false) {
|
||||
content::POST_STATUS_DRAFT.to_string()
|
||||
} else {
|
||||
content::POST_STATUS_PUBLISHED.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_visibility(value: Option<String>) -> String {
|
||||
content::normalize_post_visibility(value.as_deref())
|
||||
}
|
||||
|
||||
fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
|
||||
let wanted = wanted_tag.trim().to_lowercase();
|
||||
|
||||
post.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
|
||||
let normalized = tag.trim().to_lowercase();
|
||||
normalized == wanted
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn effective_status(post: &Model) -> String {
|
||||
content::effective_post_state(
|
||||
post.status.as_deref().unwrap_or(content::POST_STATUS_PUBLISHED),
|
||||
post.publish_at,
|
||||
post.unpublish_at,
|
||||
Utc::now().fixed_offset(),
|
||||
)
|
||||
}
|
||||
|
||||
fn listed_publicly(post: &Model) -> bool {
|
||||
content::is_post_listed_publicly(post, Utc::now().fixed_offset())
|
||||
}
|
||||
|
||||
fn publicly_accessible(post: &Model) -> bool {
|
||||
content::is_post_publicly_accessible(post, Utc::now().fixed_offset())
|
||||
}
|
||||
|
||||
fn parse_optional_markdown_datetime(
|
||||
value: Option<&str>,
|
||||
) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
||||
let value = value?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
chrono::DateTime::parse_from_rfc3339(value).ok().or_else(|| {
|
||||
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
|
||||
.ok()
|
||||
.and_then(|date| date.and_hms_opt(0, 0, 0))
|
||||
.and_then(|naive| {
|
||||
chrono::FixedOffset::east_opt(0)?
|
||||
.from_local_datetime(&naive)
|
||||
.single()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn markdown_post_listed_publicly(post: &content::MarkdownPost) -> bool {
|
||||
content::effective_post_state(
|
||||
&post.status,
|
||||
parse_optional_markdown_datetime(post.publish_at.as_deref()),
|
||||
parse_optional_markdown_datetime(post.unpublish_at.as_deref()),
|
||||
Utc::now().fixed_offset(),
|
||||
) == content::POST_STATUS_PUBLISHED
|
||||
&& post.visibility == content::POST_VISIBILITY_PUBLIC
|
||||
}
|
||||
|
||||
fn should_include_post(
|
||||
post: &Model,
|
||||
query: &ListQuery,
|
||||
preview: bool,
|
||||
include_private: bool,
|
||||
include_redirects: bool,
|
||||
) -> bool {
|
||||
if !preview && !listed_publicly(post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if query.listed_only.unwrap_or(!preview) && !listed_publicly(post) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !include_private
|
||||
&& content::normalize_post_visibility(post.visibility.as_deref())
|
||||
== content::POST_VISIBILITY_PRIVATE
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !include_redirects
|
||||
&& post
|
||||
.redirect_to
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(slug) = &query.slug {
|
||||
if post.slug != *slug {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(category) = &query.category {
|
||||
if post
|
||||
.category
|
||||
.as_deref()
|
||||
.map(|value| !value.eq_ignore_ascii_case(category))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(post_type) = &query.post_type {
|
||||
if post
|
||||
.post_type
|
||||
.as_deref()
|
||||
.map(|value| !value.eq_ignore_ascii_case(post_type))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pinned) = query.pinned {
|
||||
if post.pinned.unwrap_or(false) != pinned {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tag) = &query.tag {
|
||||
if !post_has_tag(post, tag) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(status) = &query.status {
|
||||
if effective_status(post) != content::normalize_post_status(Some(status)) && effective_status(post) != status.trim().to_ascii_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(visibility) = &query.visibility {
|
||||
if content::normalize_post_visibility(post.visibility.as_deref())
|
||||
!= content::normalize_post_visibility(Some(visibility))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(search) = &query.search {
|
||||
let wanted = search.trim().to_lowercase();
|
||||
let haystack = [
|
||||
post.title.as_deref().unwrap_or_default(),
|
||||
post.description.as_deref().unwrap_or_default(),
|
||||
post.content.as_deref().unwrap_or_default(),
|
||||
post.category.as_deref().unwrap_or_default(),
|
||||
&post.slug,
|
||||
]
|
||||
.join("\n")
|
||||
.to_lowercase();
|
||||
|
||||
if !haystack.contains(&wanted)
|
||||
&& !post
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(|tag| tag.as_str())
|
||||
.any(|tag| tag.to_lowercase().contains(&wanted))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
async fn load_item_by_slug_once(ctx: &AppContext, slug: &str) -> Result<Option<Model>> {
|
||||
Entity::find()
|
||||
.filter(Column::Slug.eq(slug))
|
||||
.one(&ctx.db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn resolve_post_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
|
||||
let mut current_slug = normalize_slug_key(slug);
|
||||
if current_slug.is_empty() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
loop {
|
||||
if !visited.insert(current_slug.clone()) {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
if let Some(post) = load_item_by_slug_once(ctx, ¤t_slug).await? {
|
||||
let next_slug = post
|
||||
.redirect_to
|
||||
.as_deref()
|
||||
.map(normalize_slug_key)
|
||||
.filter(|value| !value.is_empty() && *value != post.slug);
|
||||
|
||||
if let Some(next_slug) = next_slug {
|
||||
current_slug = next_slug;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(post);
|
||||
}
|
||||
|
||||
let candidates = Entity::find().all(&ctx.db).await?;
|
||||
let Some(candidate) = candidates.into_iter().find(|item| {
|
||||
content::post_redirects_from_json(&item.redirect_from)
|
||||
.into_iter()
|
||||
.any(|redirect| redirect.eq_ignore_ascii_case(¤t_slug))
|
||||
}) else {
|
||||
return Err(Error::NotFound);
|
||||
};
|
||||
|
||||
let next_slug = candidate
|
||||
.redirect_to
|
||||
.as_deref()
|
||||
.map(normalize_slug_key)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| candidate.slug.clone());
|
||||
|
||||
if next_slug == candidate.slug {
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
current_slug = next_slug;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
@@ -21,6 +320,15 @@ pub struct Params {
|
||||
pub image: Option<String>,
|
||||
pub images: Option<serde_json::Value>,
|
||||
pub pinned: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub publish_at: Option<String>,
|
||||
pub unpublish_at: Option<String>,
|
||||
pub canonical_url: Option<String>,
|
||||
pub noindex: Option<bool>,
|
||||
pub og_image: Option<String>,
|
||||
pub redirect_from: Option<serde_json::Value>,
|
||||
pub redirect_to: Option<String>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
@@ -35,6 +343,27 @@ impl Params {
|
||||
item.image = Set(self.image.clone());
|
||||
item.images = Set(self.images.clone());
|
||||
item.pinned = Set(self.pinned);
|
||||
item.status = Set(self.status.clone().map(|value| requested_status(Some(value), None)));
|
||||
item.visibility = Set(
|
||||
self.visibility
|
||||
.clone()
|
||||
.map(|value| normalize_visibility(Some(value))),
|
||||
);
|
||||
item.publish_at = Set(
|
||||
self.publish_at
|
||||
.clone()
|
||||
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()),
|
||||
);
|
||||
item.unpublish_at = Set(
|
||||
self.unpublish_at
|
||||
.clone()
|
||||
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()),
|
||||
);
|
||||
item.canonical_url = Set(self.canonical_url.clone());
|
||||
item.noindex = Set(self.noindex);
|
||||
item.og_image = Set(self.og_image.clone());
|
||||
item.redirect_from = Set(self.redirect_from.clone());
|
||||
item.redirect_to = Set(self.redirect_to.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +376,24 @@ pub struct ListQuery {
|
||||
#[serde(alias = "type")]
|
||||
pub post_type: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub listed_only: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub include_private: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub include_redirects: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub preview: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct LookupQuery {
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub preview: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub include_private: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -66,6 +413,15 @@ pub struct MarkdownCreateParams {
|
||||
pub image: Option<String>,
|
||||
pub images: Option<Vec<String>>,
|
||||
pub pinned: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub publish_at: Option<String>,
|
||||
pub unpublish_at: Option<String>,
|
||||
pub canonical_url: Option<String>,
|
||||
pub noindex: Option<bool>,
|
||||
pub og_image: Option<String>,
|
||||
pub redirect_from: Option<Vec<String>>,
|
||||
pub redirect_to: Option<String>,
|
||||
pub published: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -88,174 +444,211 @@ pub struct MarkdownImportResponse {
|
||||
pub slugs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn load_item_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
|
||||
let item = Entity::find()
|
||||
.filter(Column::Slug.eq(slug))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
|
||||
let wanted = wanted_tag.trim().to_lowercase();
|
||||
|
||||
post.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
|
||||
let normalized = tag.trim().to_lowercase();
|
||||
normalized == wanted
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
|
||||
let preview = request_preview_mode(query.preview, &headers);
|
||||
let include_private = preview && query.include_private.unwrap_or(true);
|
||||
let include_redirects = query.include_redirects.unwrap_or(preview);
|
||||
|
||||
let posts = Entity::find()
|
||||
.order_by_desc(Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let filtered: Vec<Model> = posts
|
||||
let filtered = posts
|
||||
.into_iter()
|
||||
.filter(|post| {
|
||||
if let Some(slug) = &query.slug {
|
||||
if post.slug != *slug {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(category) = &query.category {
|
||||
if post
|
||||
.category
|
||||
.as_deref()
|
||||
.map(|value| !value.eq_ignore_ascii_case(category))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(post_type) = &query.post_type {
|
||||
if post
|
||||
.post_type
|
||||
.as_deref()
|
||||
.map(|value| !value.eq_ignore_ascii_case(post_type))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pinned) = query.pinned {
|
||||
if post.pinned.unwrap_or(false) != pinned {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tag) = &query.tag {
|
||||
if !post_has_tag(post, tag) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(search) = &query.search {
|
||||
let wanted = search.trim().to_lowercase();
|
||||
let haystack = [
|
||||
post.title.as_deref().unwrap_or_default(),
|
||||
post.description.as_deref().unwrap_or_default(),
|
||||
post.content.as_deref().unwrap_or_default(),
|
||||
post.category.as_deref().unwrap_or_default(),
|
||||
&post.slug,
|
||||
]
|
||||
.join("\n")
|
||||
.to_lowercase();
|
||||
|
||||
if !haystack.contains(&wanted)
|
||||
&& !post
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(|tag| tag.as_str())
|
||||
.any(|tag| tag.to_lowercase().contains(&wanted))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
.filter(|post| should_include_post(post, &query, preview, include_private, include_redirects))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format::json(filtered)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||
pub async fn add(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
};
|
||||
params.update(&mut item);
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.create",
|
||||
"post",
|
||||
Some(item.id.to_string()),
|
||||
Some(item.slug.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let mut item = item.into_active_model();
|
||||
let actor = check_auth(&headers)?;
|
||||
let previous = load_item(&ctx, id).await?;
|
||||
let was_public = content::is_post_listed_publicly(&previous, Utc::now().fixed_offset());
|
||||
let previous_slug = previous.slug.clone();
|
||||
|
||||
let mut item = previous.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
let is_public = content::is_post_listed_publicly(&item, Utc::now().fixed_offset());
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.update",
|
||||
"post",
|
||||
Some(item.id.to_string()),
|
||||
Some(item.slug.clone()),
|
||||
Some(serde_json::json!({
|
||||
"previous_slug": previous_slug,
|
||||
"published": is_public,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_public && !was_public {
|
||||
let post = content::MarkdownPost {
|
||||
title: item.title.clone().unwrap_or_else(|| item.slug.clone()),
|
||||
slug: item.slug.clone(),
|
||||
description: item.description.clone(),
|
||||
content: item.content.clone().unwrap_or_default(),
|
||||
category: item.category.clone(),
|
||||
tags: item
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(ToString::to_string))
|
||||
.collect(),
|
||||
post_type: item.post_type.clone().unwrap_or_else(|| "article".to_string()),
|
||||
image: item.image.clone(),
|
||||
images: item
|
||||
.images
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(ToString::to_string))
|
||||
.collect(),
|
||||
pinned: item.pinned.unwrap_or(false),
|
||||
status: item.status.clone().unwrap_or_else(|| content::POST_STATUS_PUBLISHED.to_string()),
|
||||
visibility: item
|
||||
.visibility
|
||||
.clone()
|
||||
.unwrap_or_else(|| content::POST_VISIBILITY_PUBLIC.to_string()),
|
||||
publish_at: item.publish_at.map(|value| value.to_rfc3339()),
|
||||
unpublish_at: item.unpublish_at.map(|value| value.to_rfc3339()),
|
||||
canonical_url: item.canonical_url.clone(),
|
||||
noindex: item.noindex.unwrap_or(false),
|
||||
og_image: item.og_image.clone(),
|
||||
redirect_from: content::post_redirects_from_json(&item.redirect_from),
|
||||
redirect_to: item.redirect_to.clone(),
|
||||
file_path: content::markdown_post_path(&item.slug)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
};
|
||||
let _ = subscriptions::notify_post_published(&ctx, &post).await;
|
||||
}
|
||||
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
pub async fn remove(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let slug = item.slug.clone();
|
||||
item.delete(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.delete",
|
||||
"post",
|
||||
Some(id.to_string()),
|
||||
Some(slug),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
pub async fn get_one(
|
||||
Path(id): Path<i32>,
|
||||
Query(query): Query<LookupQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(load_item(&ctx, id).await?)
|
||||
let preview = request_preview_mode(query.preview, &headers);
|
||||
let post = load_item(&ctx, id).await?;
|
||||
|
||||
if !preview && !publicly_accessible(&post) {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
format::json(post)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_by_slug(
|
||||
Path(slug): Path<String>,
|
||||
Query(query): Query<LookupQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(load_item_by_slug(&ctx, &slug).await?)
|
||||
let preview = request_preview_mode(query.preview, &headers);
|
||||
let include_private = preview && query.include_private.unwrap_or(true);
|
||||
let post = resolve_post_by_slug(&ctx, &slug).await?;
|
||||
|
||||
if !preview && !publicly_accessible(&post) {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
if !include_private
|
||||
&& content::normalize_post_visibility(post.visibility.as_deref())
|
||||
== content::POST_VISIBILITY_PRIVATE
|
||||
{
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
format::json(post)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_markdown_by_slug(
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth(&headers)?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
let (path, markdown) = content::read_markdown_document(&slug)?;
|
||||
format::json(MarkdownDocumentResponse {
|
||||
@@ -267,12 +660,43 @@ pub async fn get_markdown_by_slug(
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_markdown_by_slug(
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<MarkdownUpdateParams>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let _ = post_revisions::capture_current_snapshot(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
&slug,
|
||||
"update",
|
||||
Some("保存文章前的自动快照"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let updated = content::write_markdown_document(&ctx, &slug, ¶ms.markdown).await?;
|
||||
let (path, markdown) = content::read_markdown_document(&updated.slug)?;
|
||||
let _ = post_revisions::capture_snapshot_from_markdown(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
&updated.slug,
|
||||
&markdown,
|
||||
"saved",
|
||||
Some("保存后的当前版本"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.markdown.update",
|
||||
"post",
|
||||
None,
|
||||
Some(updated.slug.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(MarkdownDocumentResponse {
|
||||
slug: updated.slug,
|
||||
@@ -283,9 +707,11 @@ pub async fn update_markdown_by_slug(
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn create_markdown(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<MarkdownCreateParams>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let title = params.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(Error::BadRequest("title is required".to_string()));
|
||||
@@ -305,11 +731,42 @@ pub async fn create_markdown(
|
||||
image: params.image,
|
||||
images: params.images.unwrap_or_default(),
|
||||
pinned: params.pinned.unwrap_or(false),
|
||||
published: params.published.unwrap_or(true),
|
||||
status: requested_status(params.status, params.published),
|
||||
visibility: normalize_visibility(params.visibility),
|
||||
publish_at: params.publish_at,
|
||||
unpublish_at: params.unpublish_at,
|
||||
canonical_url: params.canonical_url,
|
||||
noindex: params.noindex.unwrap_or(false),
|
||||
og_image: params.og_image,
|
||||
redirect_from: params.redirect_from.unwrap_or_default(),
|
||||
redirect_to: params.redirect_to,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let (path, markdown) = content::read_markdown_document(&created.slug)?;
|
||||
let _ = post_revisions::capture_snapshot_from_markdown(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
&created.slug,
|
||||
&markdown,
|
||||
"create",
|
||||
Some("新建文章"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.markdown.create",
|
||||
"post",
|
||||
None,
|
||||
Some(created.slug.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if markdown_post_listed_publicly(&created) {
|
||||
let _ = subscriptions::notify_post_published(&ctx, &created).await;
|
||||
}
|
||||
|
||||
format::json(MarkdownDocumentResponse {
|
||||
slug: created.slug,
|
||||
@@ -320,9 +777,11 @@ pub async fn create_markdown(
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn import_markdown(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
while let Some(field) = multipart
|
||||
@@ -345,6 +804,35 @@ pub async fn import_markdown(
|
||||
}
|
||||
|
||||
let imported = content::import_markdown_documents(&ctx, files).await?;
|
||||
for item in &imported {
|
||||
if let Ok((_path, markdown)) = content::read_markdown_document(&item.slug) {
|
||||
let _ = post_revisions::capture_snapshot_from_markdown(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
&item.slug,
|
||||
&markdown,
|
||||
"import",
|
||||
Some("批量导入 Markdown"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if markdown_post_listed_publicly(item) {
|
||||
let _ = subscriptions::notify_post_published(&ctx, item).await;
|
||||
}
|
||||
}
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.markdown.import",
|
||||
"post_import",
|
||||
None,
|
||||
Some(format!("{} files", imported.len())),
|
||||
Some(serde_json::json!({
|
||||
"slugs": imported.iter().map(|item| item.slug.clone()).collect::<Vec<_>>(),
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(MarkdownImportResponse {
|
||||
count: imported.len(),
|
||||
@@ -354,10 +842,31 @@ pub async fn import_markdown(
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn delete_markdown_by_slug(
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let _ = post_revisions::capture_current_snapshot(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
&slug,
|
||||
"delete",
|
||||
Some("删除前自动快照"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
content::delete_markdown_post(&ctx, &slug).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"post.markdown.delete",
|
||||
"post",
|
||||
None,
|
||||
Some(slug.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::json(MarkdownDeleteResponse {
|
||||
slug,
|
||||
deleted: true,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
models::_entities::reviews::{self, Entity as ReviewEntity},
|
||||
services::storage,
|
||||
services::{admin_audit, storage},
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -56,9 +60,11 @@ pub async fn get_one(
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(req): Json<CreateReviewRequest>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let new_review = reviews::ActiveModel {
|
||||
title: Set(Some(req.title)),
|
||||
review_type: Set(Some(req.review_type)),
|
||||
@@ -76,14 +82,26 @@ pub async fn create(
|
||||
};
|
||||
|
||||
let review = new_review.insert(&ctx.db).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"review.create",
|
||||
"review",
|
||||
Some(review.id.to_string()),
|
||||
review.title.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::json(review)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(req): Json<UpdateReviewRequest>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||
|
||||
let Some(existing_review) = review else {
|
||||
@@ -132,24 +150,47 @@ pub async fn update(
|
||||
tracing::warn!("failed to cleanup replaced review cover: {error}");
|
||||
}
|
||||
}
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"review.update",
|
||||
"review",
|
||||
Some(review.id.to_string()),
|
||||
review.title.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::json(review)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let actor = check_auth(&headers)?;
|
||||
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||
|
||||
match review {
|
||||
Some(r) => {
|
||||
let cover = r.cover.clone();
|
||||
let title = r.title.clone();
|
||||
r.delete(&ctx.db).await?;
|
||||
if let Some(cover) = cover.filter(|value| !value.trim().is_empty()) {
|
||||
if let Err(error) = storage::delete_managed_url(&ctx, &cover).await {
|
||||
tracing::warn!("failed to cleanup deleted review cover: {error}");
|
||||
}
|
||||
}
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
Some(&actor),
|
||||
"review.delete",
|
||||
"review",
|
||||
Some(id.to_string()),
|
||||
title,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
format::empty()
|
||||
}
|
||||
None => Err(Error::NotFound),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::time::Instant;
|
||||
use std::{collections::HashSet, time::Instant};
|
||||
|
||||
use crate::models::_entities::posts;
|
||||
use crate::services::{analytics, content};
|
||||
use crate::{
|
||||
controllers::site_settings,
|
||||
models::_entities::posts,
|
||||
services::{abuse_guard, analytics, content},
|
||||
};
|
||||
|
||||
fn deserialize_boolish_option<'de, D>(
|
||||
deserializer: D,
|
||||
@@ -26,6 +28,243 @@ where
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn normalize_text(value: &str) -> String {
|
||||
value
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn tokenize(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(|ch: char| !ch.is_alphanumeric() && ch != '-' && ch != '_')
|
||||
.map(normalize_text)
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn levenshtein_distance(left: &str, right: &str) -> usize {
|
||||
if left == right {
|
||||
return 0;
|
||||
}
|
||||
if left.is_empty() {
|
||||
return right.chars().count();
|
||||
}
|
||||
if right.is_empty() {
|
||||
return left.chars().count();
|
||||
}
|
||||
|
||||
let right_chars = right.chars().collect::<Vec<_>>();
|
||||
let mut prev = (0..=right_chars.len()).collect::<Vec<_>>();
|
||||
|
||||
for (i, left_ch) in left.chars().enumerate() {
|
||||
let mut curr = vec![i + 1; right_chars.len() + 1];
|
||||
for (j, right_ch) in right_chars.iter().enumerate() {
|
||||
let cost = usize::from(left_ch != *right_ch);
|
||||
curr[j + 1] = (curr[j] + 1)
|
||||
.min(prev[j + 1] + 1)
|
||||
.min(prev[j] + cost);
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
prev[right_chars.len()]
|
||||
}
|
||||
|
||||
fn parse_synonym_groups(value: &Option<Value>) -> Vec<Vec<String>> {
|
||||
value
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|item| item.as_str().map(ToString::to_string))
|
||||
.map(|item| {
|
||||
let normalized = item.replace("=>", ",").replace('|', ",");
|
||||
normalized
|
||||
.split([',', ','])
|
||||
.map(normalize_text)
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|group| !group.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expand_search_terms(query: &str, synonym_groups: &[Vec<String>]) -> Vec<String> {
|
||||
let normalized_query = normalize_text(query);
|
||||
let query_tokens = tokenize(query);
|
||||
let mut expanded = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
if !normalized_query.is_empty() && seen.insert(normalized_query.clone()) {
|
||||
expanded.push(normalized_query.clone());
|
||||
}
|
||||
|
||||
for token in &query_tokens {
|
||||
if seen.insert(token.clone()) {
|
||||
expanded.push(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for group in synonym_groups {
|
||||
let matched = group.iter().any(|item| {
|
||||
*item == normalized_query
|
||||
|| query_tokens.iter().any(|token| token == item)
|
||||
|| normalized_query.contains(item)
|
||||
});
|
||||
|
||||
if matched {
|
||||
for token in group {
|
||||
if seen.insert(token.clone()) {
|
||||
expanded.push(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expanded
|
||||
}
|
||||
|
||||
fn candidate_terms(posts: &[posts::Model]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
for post in posts {
|
||||
for source in [
|
||||
post.title.as_deref().unwrap_or_default(),
|
||||
post.category.as_deref().unwrap_or_default(),
|
||||
&post.slug,
|
||||
] {
|
||||
for token in tokenize(source) {
|
||||
if token.len() >= 3 && seen.insert(token.clone()) {
|
||||
candidates.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tags) = post.tags.as_ref().and_then(Value::as_array) {
|
||||
for token in tags.iter().filter_map(Value::as_str).flat_map(tokenize) {
|
||||
if token.len() >= 2 && seen.insert(token.clone()) {
|
||||
candidates.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_spelling_fallback(query: &str, posts: &[posts::Model], synonym_groups: &[Vec<String>]) -> Vec<String> {
|
||||
let primary_token = tokenize(query).into_iter().next().unwrap_or_default();
|
||||
if primary_token.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut nearest = candidate_terms(posts)
|
||||
.into_iter()
|
||||
.map(|candidate| {
|
||||
let distance = levenshtein_distance(&primary_token, &candidate);
|
||||
(candidate, distance)
|
||||
})
|
||||
.filter(|(_, distance)| *distance <= 2)
|
||||
.collect::<Vec<_>>();
|
||||
nearest.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)));
|
||||
|
||||
nearest
|
||||
.into_iter()
|
||||
.take(3)
|
||||
.flat_map(|(candidate, _)| expand_search_terms(&candidate, synonym_groups))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn post_has_tag(post: &posts::Model, wanted_tag: &str) -> bool {
|
||||
let wanted = normalize_text(wanted_tag);
|
||||
|
||||
post.tags
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(normalize_text)
|
||||
.any(|tag| tag == wanted)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn score_post(post: &posts::Model, query: &str, terms: &[String]) -> f64 {
|
||||
let normalized_query = normalize_text(query);
|
||||
let title = normalize_text(post.title.as_deref().unwrap_or_default());
|
||||
let description = normalize_text(post.description.as_deref().unwrap_or_default());
|
||||
let content_text = normalize_text(post.content.as_deref().unwrap_or_default());
|
||||
let category = normalize_text(post.category.as_deref().unwrap_or_default());
|
||||
let slug = normalize_text(&post.slug);
|
||||
let tags = post
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|item| item.as_str().map(normalize_text))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut score = 0.0;
|
||||
|
||||
if !normalized_query.is_empty() {
|
||||
if title.contains(&normalized_query) {
|
||||
score += 6.0;
|
||||
}
|
||||
if description.contains(&normalized_query) {
|
||||
score += 4.0;
|
||||
}
|
||||
if slug.contains(&normalized_query) {
|
||||
score += 4.0;
|
||||
}
|
||||
if category.contains(&normalized_query) {
|
||||
score += 3.0;
|
||||
}
|
||||
if tags.iter().any(|tag| tag.contains(&normalized_query)) {
|
||||
score += 4.0;
|
||||
}
|
||||
if content_text.contains(&normalized_query) {
|
||||
score += 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
for term in terms {
|
||||
if term.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if title.contains(term) {
|
||||
score += 3.5;
|
||||
}
|
||||
if description.contains(term) {
|
||||
score += 2.2;
|
||||
}
|
||||
if slug.contains(term) {
|
||||
score += 2.0;
|
||||
}
|
||||
if category.contains(term) {
|
||||
score += 1.8;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == term) {
|
||||
score += 2.5;
|
||||
} else if tags.iter().any(|tag| tag.contains(term)) {
|
||||
score += 1.5;
|
||||
}
|
||||
if content_text.contains(term) {
|
||||
score += 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
|
||||
query.preview.unwrap_or(false)
|
||||
|| headers
|
||||
@@ -39,11 +278,15 @@ fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
|
||||
pub struct SearchQuery {
|
||||
pub q: Option<String>,
|
||||
pub limit: Option<u64>,
|
||||
pub category: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
#[serde(alias = "type")]
|
||||
pub post_type: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||
pub preview: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, FromQueryResult)]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub id: i32,
|
||||
pub title: Option<String>,
|
||||
@@ -59,131 +302,6 @@ pub struct SearchResult {
|
||||
pub rank: f64,
|
||||
}
|
||||
|
||||
fn search_sql() -> &'static str {
|
||||
r#"
|
||||
SELECT
|
||||
p.id,
|
||||
p.title,
|
||||
p.slug,
|
||||
p.description,
|
||||
p.content,
|
||||
p.category,
|
||||
p.tags,
|
||||
p.post_type,
|
||||
p.pinned,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
ts_rank_cd(
|
||||
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D'),
|
||||
plainto_tsquery('simple', $1)
|
||||
)::float8 AS rank
|
||||
FROM posts p
|
||||
WHERE (
|
||||
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D')
|
||||
) @@ plainto_tsquery('simple', $1)
|
||||
ORDER BY rank DESC, p.created_at DESC
|
||||
LIMIT $2
|
||||
"#
|
||||
}
|
||||
|
||||
fn app_level_rank(post: &posts::Model, wanted: &str) -> f64 {
|
||||
let wanted_lower = wanted.to_lowercase();
|
||||
let mut rank = 0.0;
|
||||
|
||||
if post
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 4.0;
|
||||
}
|
||||
|
||||
if post
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 2.5;
|
||||
}
|
||||
|
||||
if post
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 1.0;
|
||||
}
|
||||
|
||||
if post
|
||||
.category
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase()
|
||||
.contains(&wanted_lower)
|
||||
{
|
||||
rank += 1.5;
|
||||
}
|
||||
|
||||
if post
|
||||
.tags
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag.to_lowercase().contains(&wanted_lower))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
rank += 2.0;
|
||||
}
|
||||
|
||||
rank
|
||||
}
|
||||
|
||||
async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result<Vec<SearchResult>> {
|
||||
let mut results = posts::Entity::find().all(&ctx.db).await?;
|
||||
results.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
|
||||
Ok(results
|
||||
.into_iter()
|
||||
.map(|post| {
|
||||
let rank = app_level_rank(&post, q);
|
||||
(post, rank)
|
||||
})
|
||||
.filter(|(_, rank)| *rank > 0.0)
|
||||
.take(limit as usize)
|
||||
.map(|(post, rank)| SearchResult {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
content: post.content,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
post_type: post.post_type,
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
rank,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn search(
|
||||
Query(query): Query<SearchQuery>,
|
||||
@@ -199,26 +317,107 @@ pub async fn search(
|
||||
return format::json(Vec::<SearchResult>::new());
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
if !preview_search {
|
||||
abuse_guard::enforce_public_scope(
|
||||
"search",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
Some(&q),
|
||||
)?;
|
||||
}
|
||||
|
||||
let results = if ctx.db.get_database_backend() == DatabaseBackend::Postgres {
|
||||
let statement = Statement::from_sql_and_values(
|
||||
DbBackend::Postgres,
|
||||
search_sql(),
|
||||
[q.clone().into(), (limit as i64).into()],
|
||||
);
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
||||
let settings = site_settings::load_current(&ctx).await.ok();
|
||||
let synonym_groups = settings
|
||||
.as_ref()
|
||||
.map(|item| parse_synonym_groups(&item.search_synonyms))
|
||||
.unwrap_or_default();
|
||||
|
||||
match SearchResult::find_by_statement(statement)
|
||||
.all(&ctx.db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) if !rows.is_empty() => rows,
|
||||
Ok(_) => fallback_search(&ctx, &q, limit).await?,
|
||||
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
||||
let mut all_posts = posts::Entity::find()
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|post| {
|
||||
preview_search
|
||||
|| content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(category) = query.category.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
all_posts.retain(|post| {
|
||||
post.category
|
||||
.as_deref()
|
||||
.map(|value| value.eq_ignore_ascii_case(category))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(tag) = query.tag.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
all_posts.retain(|post| post_has_tag(post, tag));
|
||||
}
|
||||
|
||||
if let Some(post_type) = query.post_type.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
all_posts.retain(|post| {
|
||||
post.post_type
|
||||
.as_deref()
|
||||
.map(|value| value.eq_ignore_ascii_case(post_type))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
let mut expanded_terms = expand_search_terms(&q, &synonym_groups);
|
||||
let mut results = all_posts
|
||||
.iter()
|
||||
.map(|post| (post, score_post(post, &q, &expanded_terms)))
|
||||
.filter(|(_, rank)| *rank > 0.0)
|
||||
.map(|(post, rank)| SearchResult {
|
||||
id: post.id,
|
||||
title: post.title.clone(),
|
||||
slug: post.slug.clone(),
|
||||
description: post.description.clone(),
|
||||
content: post.content.clone(),
|
||||
category: post.category.clone(),
|
||||
tags: post.tags.clone(),
|
||||
post_type: post.post_type.clone(),
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
rank,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if results.is_empty() {
|
||||
expanded_terms = find_spelling_fallback(&q, &all_posts, &synonym_groups);
|
||||
if !expanded_terms.is_empty() {
|
||||
results = all_posts
|
||||
.iter()
|
||||
.map(|post| (post, score_post(post, &q, &expanded_terms)))
|
||||
.filter(|(_, rank)| *rank > 0.0)
|
||||
.map(|(post, rank)| SearchResult {
|
||||
id: post.id,
|
||||
title: post.title.clone(),
|
||||
slug: post.slug.clone(),
|
||||
description: post.description.clone(),
|
||||
content: post.content.clone(),
|
||||
category: post.category.clone(),
|
||||
tags: post.tags.clone(),
|
||||
post_type: post.post_type.clone(),
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
rank,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
} else {
|
||||
fallback_search(&ctx, &q, limit).await?
|
||||
};
|
||||
}
|
||||
|
||||
results.sort_by(|left, right| {
|
||||
right
|
||||
.rank
|
||||
.partial_cmp(&left.rank)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
results.truncate(limit);
|
||||
|
||||
if !preview_search {
|
||||
analytics::record_search_event(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -11,7 +12,9 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
models::_entities::{
|
||||
categories, friend_links, posts, site_settings::{self, ActiveModel, Entity, Model}, tags,
|
||||
categories, friend_links, posts,
|
||||
site_settings::{self, ActiveModel, Entity, Model},
|
||||
tags,
|
||||
},
|
||||
services::{ai, content},
|
||||
};
|
||||
@@ -130,6 +133,18 @@ pub struct SiteSettingsPayload {
|
||||
pub media_r2_access_key_id: Option<String>,
|
||||
#[serde(default, alias = "mediaR2SecretAccessKey")]
|
||||
pub media_r2_secret_access_key: Option<String>,
|
||||
#[serde(default, alias = "seoDefaultOgImage")]
|
||||
pub seo_default_og_image: Option<String>,
|
||||
#[serde(default, alias = "seoDefaultTwitterHandle")]
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
#[serde(default, alias = "notificationWebhookUrl")]
|
||||
pub notification_webhook_url: Option<String>,
|
||||
#[serde(default, alias = "notificationCommentEnabled")]
|
||||
pub notification_comment_enabled: Option<bool>,
|
||||
#[serde(default, alias = "notificationFriendLinkEnabled")]
|
||||
pub notification_friend_link_enabled: Option<bool>,
|
||||
#[serde(default, alias = "searchSynonyms")]
|
||||
pub search_synonyms: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -154,6 +169,8 @@ pub struct PublicSiteSettingsResponse {
|
||||
pub music_playlist: Option<serde_json::Value>,
|
||||
pub ai_enabled: bool,
|
||||
pub paragraph_comments_enabled: bool,
|
||||
pub seo_default_og_image: Option<String>,
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -171,6 +188,9 @@ pub struct HomePageResponse {
|
||||
pub tags: Vec<tags::Model>,
|
||||
pub friend_links: Vec<friend_links::Model>,
|
||||
pub categories: Vec<HomeCategorySummary>,
|
||||
pub content_overview: crate::services::analytics::ContentAnalyticsOverview,
|
||||
pub popular_posts: Vec<crate::services::analytics::AnalyticsPopularPost>,
|
||||
pub content_ranges: Vec<crate::services::analytics::PublicContentWindowHighlights>,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
@@ -188,6 +208,13 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
||||
value.map(|item| item.clamp(min, max))
|
||||
}
|
||||
|
||||
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||
values
|
||||
.into_iter()
|
||||
.filter_map(|item| normalize_optional_string(Some(item)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn create_ai_provider_id() -> String {
|
||||
format!("provider-{}", Uuid::new_v4().simple())
|
||||
}
|
||||
@@ -525,6 +552,27 @@ impl SiteSettingsPayload {
|
||||
item.media_r2_secret_access_key =
|
||||
normalize_optional_string(Some(media_r2_secret_access_key));
|
||||
}
|
||||
if let Some(seo_default_og_image) = self.seo_default_og_image {
|
||||
item.seo_default_og_image = normalize_optional_string(Some(seo_default_og_image));
|
||||
}
|
||||
if let Some(seo_default_twitter_handle) = self.seo_default_twitter_handle {
|
||||
item.seo_default_twitter_handle =
|
||||
normalize_optional_string(Some(seo_default_twitter_handle));
|
||||
}
|
||||
if let Some(notification_webhook_url) = self.notification_webhook_url {
|
||||
item.notification_webhook_url =
|
||||
normalize_optional_string(Some(notification_webhook_url));
|
||||
}
|
||||
if let Some(notification_comment_enabled) = self.notification_comment_enabled {
|
||||
item.notification_comment_enabled = Some(notification_comment_enabled);
|
||||
}
|
||||
if let Some(notification_friend_link_enabled) = self.notification_friend_link_enabled {
|
||||
item.notification_friend_link_enabled = Some(notification_friend_link_enabled);
|
||||
}
|
||||
if let Some(search_synonyms) = self.search_synonyms {
|
||||
let normalized = normalize_string_list(search_synonyms);
|
||||
item.search_synonyms = (!normalized.is_empty()).then(|| serde_json::json!(normalized));
|
||||
}
|
||||
|
||||
if provider_list_supplied {
|
||||
write_ai_provider_state(
|
||||
@@ -631,6 +679,12 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
media_r2_public_base_url: None,
|
||||
media_r2_access_key_id: None,
|
||||
media_r2_secret_access_key: None,
|
||||
seo_default_og_image: None,
|
||||
seo_default_twitter_handle: None,
|
||||
notification_webhook_url: None,
|
||||
notification_comment_enabled: Some(false),
|
||||
notification_friend_link_enabled: Some(false),
|
||||
search_synonyms: Some(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,6 +734,8 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
music_playlist: model.music_playlist,
|
||||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
||||
seo_default_og_image: model.seo_default_og_image,
|
||||
seo_default_twitter_handle: model.seo_default_twitter_handle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,9 +747,13 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let posts = posts::Entity::find()
|
||||
.order_by_desc(posts::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|post| content::is_post_listed_publicly(post, chrono::Utc::now().fixed_offset()))
|
||||
.collect::<Vec<_>>();
|
||||
let tags = tags::Entity::find().all(&ctx.db).await?;
|
||||
let friend_links = friend_links::Entity::find()
|
||||
.filter(friend_links::Column::Status.eq("approved"))
|
||||
.order_by_desc(friend_links::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
@@ -722,6 +782,9 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content_highlights =
|
||||
crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?;
|
||||
let content_ranges = crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
|
||||
|
||||
format::json(HomePageResponse {
|
||||
site_settings,
|
||||
@@ -729,6 +792,9 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
tags,
|
||||
friend_links,
|
||||
categories,
|
||||
content_overview: content_highlights.overview,
|
||||
popular_posts: content_highlights.popular_posts,
|
||||
content_ranges,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -739,10 +805,11 @@ pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update(
|
||||
headers: HeaderMap,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<SiteSettingsPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth()?;
|
||||
check_auth(&headers)?;
|
||||
|
||||
let current = load_current(&ctx).await?;
|
||||
let mut item = current;
|
||||
|
||||
202
backend/src/controllers/subscription.rs
Normal file
202
backend/src/controllers/subscription.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::services::{abuse_guard, admin_audit, subscriptions};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct PublicSubscriptionPayload {
|
||||
pub email: String,
|
||||
#[serde(default, alias = "displayName")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SubscriptionTokenPayload {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SubscriptionManageQuery {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SubscriptionManageUpdatePayload {
|
||||
pub token: String,
|
||||
#[serde(default, alias = "displayName")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub filters: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublicSubscriptionResponse {
|
||||
pub ok: bool,
|
||||
pub subscription_id: i32,
|
||||
pub status: String,
|
||||
pub requires_confirmation: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SubscriptionManageResponse {
|
||||
pub ok: bool,
|
||||
pub subscription: subscriptions::PublicSubscriptionView,
|
||||
}
|
||||
|
||||
fn public_subscription_metadata(source: Option<String>) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"source": source,
|
||||
"kind": "public-form",
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn subscribe(
|
||||
State(ctx): State<AppContext>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(payload): Json<PublicSubscriptionPayload>,
|
||||
) -> Result<Response> {
|
||||
let email = payload.email.trim().to_ascii_lowercase();
|
||||
abuse_guard::enforce_public_scope(
|
||||
"subscription",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
Some(&email),
|
||||
)?;
|
||||
|
||||
let result = subscriptions::create_public_email_subscription(
|
||||
&ctx,
|
||||
&email,
|
||||
payload.display_name,
|
||||
Some(public_subscription_metadata(payload.source)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
None,
|
||||
if result.requires_confirmation {
|
||||
"subscription.public.pending"
|
||||
} else {
|
||||
"subscription.public.active"
|
||||
},
|
||||
"subscription",
|
||||
Some(result.subscription.id.to_string()),
|
||||
Some(result.subscription.target.clone()),
|
||||
Some(serde_json::json!({
|
||||
"channel_type": result.subscription.channel_type,
|
||||
"status": result.subscription.status,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(PublicSubscriptionResponse {
|
||||
ok: true,
|
||||
subscription_id: result.subscription.id,
|
||||
status: result.subscription.status,
|
||||
requires_confirmation: result.requires_confirmation,
|
||||
message: result.message,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn confirm(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<SubscriptionTokenPayload>,
|
||||
) -> Result<Response> {
|
||||
let item = subscriptions::confirm_subscription(&ctx, &payload.token).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
None,
|
||||
"subscription.public.confirm",
|
||||
"subscription",
|
||||
Some(item.id.to_string()),
|
||||
Some(item.target.clone()),
|
||||
Some(serde_json::json!({ "channel_type": item.channel_type })),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(SubscriptionManageResponse {
|
||||
ok: true,
|
||||
subscription: subscriptions::to_public_subscription_view(&item),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn manage(
|
||||
State(ctx): State<AppContext>,
|
||||
Query(query): Query<SubscriptionManageQuery>,
|
||||
) -> Result<Response> {
|
||||
let item = subscriptions::get_subscription_by_manage_token(&ctx, &query.token).await?;
|
||||
format::json(SubscriptionManageResponse {
|
||||
ok: true,
|
||||
subscription: subscriptions::to_public_subscription_view(&item),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn update_manage(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<SubscriptionManageUpdatePayload>,
|
||||
) -> Result<Response> {
|
||||
let item = subscriptions::update_subscription_preferences(
|
||||
&ctx,
|
||||
&payload.token,
|
||||
payload.display_name,
|
||||
payload.status,
|
||||
payload.filters,
|
||||
)
|
||||
.await?;
|
||||
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
None,
|
||||
"subscription.public.update",
|
||||
"subscription",
|
||||
Some(item.id.to_string()),
|
||||
Some(item.target.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(SubscriptionManageResponse {
|
||||
ok: true,
|
||||
subscription: subscriptions::to_public_subscription_view(&item),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn unsubscribe(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<SubscriptionTokenPayload>,
|
||||
) -> Result<Response> {
|
||||
let item = subscriptions::unsubscribe_subscription(&ctx, &payload.token).await?;
|
||||
admin_audit::log_event(
|
||||
&ctx,
|
||||
None,
|
||||
"subscription.public.unsubscribe",
|
||||
"subscription",
|
||||
Some(item.id.to_string()),
|
||||
Some(item.target.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(SubscriptionManageResponse {
|
||||
ok: true,
|
||||
subscription: subscriptions::to_public_subscription_view(&item),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/subscriptions")
|
||||
.add("/", post(subscribe))
|
||||
.add("/confirm", post(confirm))
|
||||
.add("/manage", get(manage).patch(update_manage))
|
||||
.add("/unsubscribe", post(unsubscribe))
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod content_sync;
|
||||
pub mod view_engine;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::{Extension, Router as AxumRouter};
|
||||
use fluent_templates::{ArcLoader, FluentLoader};
|
||||
use loco_rs::{
|
||||
app::{AppContext, Initializer},
|
||||
controller::views::{engines, ViewEngine},
|
||||
Error, Result,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
const I18N_DIR: &str = "assets/i18n";
|
||||
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct ViewEngineInitializer;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ViewEngineInitializer {
|
||||
fn name(&self) -> String {
|
||||
"view-engine".to_string()
|
||||
}
|
||||
|
||||
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let tera_engine = if std::path::Path::new(I18N_DIR).exists() {
|
||||
let arc = std::sync::Arc::new(
|
||||
ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
|
||||
.shared_resources(Some(&[I18N_SHARED.into()]))
|
||||
.customize(|bundle| bundle.set_use_isolating(false))
|
||||
.build()
|
||||
.map_err(|e| Error::string(&e.to_string()))?,
|
||||
);
|
||||
info!("locales loaded");
|
||||
|
||||
engines::TeraView::build()?.post_process(move |tera| {
|
||||
tera.register_function("t", FluentLoader::new(arc.clone()));
|
||||
Ok(())
|
||||
})?
|
||||
} else {
|
||||
engines::TeraView::build()?
|
||||
};
|
||||
|
||||
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod subscription;
|
||||
|
||||
77
backend/src/mailers/subscription.rs
Normal file
77
backend/src/mailers/subscription.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
#![allow(non_upper_case_globals)]
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
static confirm: Dir<'_> = include_dir!("src/mailers/subscription/confirm");
|
||||
static notification: Dir<'_> = include_dir!("src/mailers/subscription/notification");
|
||||
|
||||
pub struct SubscriptionMailer {}
|
||||
impl Mailer for SubscriptionMailer {}
|
||||
|
||||
impl SubscriptionMailer {
|
||||
pub async fn send_confirmation(
|
||||
ctx: &AppContext,
|
||||
to: &str,
|
||||
site_name: Option<&str>,
|
||||
site_url: Option<&str>,
|
||||
confirm_url: &str,
|
||||
manage_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&confirm,
|
||||
mailer::Args {
|
||||
to: to.to_string(),
|
||||
locals: json!({
|
||||
"subject": "请确认你的订阅",
|
||||
"siteName": site_name.unwrap_or("Termi"),
|
||||
"siteUrl": site_url
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| ctx.config.server.full_url()),
|
||||
"confirmUrl": confirm_url,
|
||||
"manageUrl": manage_url,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_notification(
|
||||
ctx: &AppContext,
|
||||
to: &str,
|
||||
subject: &str,
|
||||
headline: &str,
|
||||
body: &str,
|
||||
site_name: Option<&str>,
|
||||
site_url: Option<&str>,
|
||||
manage_url: Option<&str>,
|
||||
unsubscribe_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
¬ification,
|
||||
mailer::Args {
|
||||
to: to.to_string(),
|
||||
locals: json!({
|
||||
"subject": subject,
|
||||
"headline": headline,
|
||||
"body": body,
|
||||
"siteName": site_name.unwrap_or("Termi"),
|
||||
"siteUrl": site_url
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| ctx.config.server.full_url()),
|
||||
"manageUrl": manage_url,
|
||||
"unsubscribeUrl": unsubscribe_url,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
25
backend/src/mailers/subscription/confirm/html.t
Normal file
25
backend/src/mailers/subscription/confirm/html.t
Normal file
@@ -0,0 +1,25 @@
|
||||
<html>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #0f172a; line-height: 1.7;">
|
||||
<div style="max-width: 640px; margin: 0 auto; padding: 24px;">
|
||||
<p style="font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: #64748b;">{{ siteName }}</p>
|
||||
<h1 style="margin-top: 8px; font-size: 24px;">请确认你的订阅</h1>
|
||||
<p style="margin-top: 20px;">为了确认这是你本人提交的邮箱,请点击下面的确认按钮。</p>
|
||||
<p style="margin-top: 24px;">
|
||||
<a href="{{ confirmUrl }}" style="display: inline-block; padding: 12px 18px; border-radius: 9999px; background: #0f172a; color: #ffffff; text-decoration: none;">确认订阅</a>
|
||||
</p>
|
||||
<p style="margin-top: 20px; font-size: 14px; color: #475569; word-break: break-all;">
|
||||
如果按钮无法点击,请直接打开:<br />
|
||||
<a href="{{ confirmUrl }}">{{ confirmUrl }}</a>
|
||||
</p>
|
||||
{% if manageUrl %}
|
||||
<p style="margin-top: 20px; font-size: 14px; color: #475569;">
|
||||
确认完成后,你可以在这里管理偏好:<br />
|
||||
<a href="{{ manageUrl }}">{{ manageUrl }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p style="margin-top: 28px; font-size: 13px; color: #64748b;">
|
||||
来自 {{ siteName }} · <a href="{{ siteUrl }}">{{ siteUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
backend/src/mailers/subscription/confirm/subject.t
Normal file
1
backend/src/mailers/subscription/confirm/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
请确认你的订阅
|
||||
13
backend/src/mailers/subscription/confirm/text.t
Normal file
13
backend/src/mailers/subscription/confirm/text.t
Normal file
@@ -0,0 +1,13 @@
|
||||
你好,
|
||||
|
||||
请点击下面的链接确认你的订阅:
|
||||
{{ confirmUrl }}
|
||||
|
||||
{% if manageUrl %}
|
||||
确认完成后,你也可以通过这个链接管理偏好:
|
||||
{{ manageUrl }}
|
||||
{% endif %}
|
||||
|
||||
--
|
||||
{{ siteName }}
|
||||
{{ siteUrl }}
|
||||
22
backend/src/mailers/subscription/notification/html.t
Normal file
22
backend/src/mailers/subscription/notification/html.t
Normal file
@@ -0,0 +1,22 @@
|
||||
<html>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #0f172a; line-height: 1.7;">
|
||||
<div style="max-width: 640px; margin: 0 auto; padding: 24px;">
|
||||
<p style="font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: #64748b;">{{ siteName }}</p>
|
||||
<h1 style="margin-top: 8px; font-size: 24px;">{{ headline }}</h1>
|
||||
<div style="margin-top: 20px; white-space: pre-wrap;">{{ body }}</div>
|
||||
{% if manageUrl or unsubscribeUrl %}
|
||||
<div style="margin-top: 24px; display: flex; flex-wrap: wrap; gap: 12px;">
|
||||
{% if manageUrl %}
|
||||
<a href="{{ manageUrl }}" style="display: inline-block; padding: 10px 16px; border-radius: 9999px; background: #0f172a; color: #ffffff; text-decoration: none;">管理订阅</a>
|
||||
{% endif %}
|
||||
{% if unsubscribeUrl %}
|
||||
<a href="{{ unsubscribeUrl }}" style="display: inline-block; padding: 10px 16px; border-radius: 9999px; border: 1px solid #cbd5e1; color: #334155; text-decoration: none;">取消订阅</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p style="margin-top: 28px; font-size: 13px; color: #64748b;">
|
||||
来自 {{ siteName }} · <a href="{{ siteUrl }}">{{ siteUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1
backend/src/mailers/subscription/notification/subject.t
Normal file
1
backend/src/mailers/subscription/notification/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
{{ subject }}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user