Fix web push delivery handling and worker console
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
Some checks failed
docker-images / resolve-build-targets (push) Successful in 5s
docker-images / build-and-push (admin) (push) Successful in 30s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled
This commit is contained in:
@@ -1,21 +1,6 @@
|
|||||||
name: ui-regression
|
name: ui-regression
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
paths:
|
|
||||||
- admin/**
|
|
||||||
- frontend/**
|
|
||||||
- playwright-smoke/**
|
|
||||||
- .gitea/workflows/ui-regression.yml
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- admin/**
|
|
||||||
- frontend/**
|
|
||||||
- playwright-smoke/**
|
|
||||||
- .gitea/workflows/ui-regression.yml
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -35,6 +20,11 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: |
|
||||||
|
frontend/pnpm-lock.yaml
|
||||||
|
admin/pnpm-lock.yaml
|
||||||
|
playwright-smoke/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -48,7 +38,15 @@ jobs:
|
|||||||
working-directory: playwright-smoke
|
working-directory: playwright-smoke
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('playwright-smoke/pnpm-lock.yaml') }}
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
working-directory: playwright-smoke
|
working-directory: playwright-smoke
|
||||||
run: pnpm exec playwright install --with-deps chromium
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
@@ -69,71 +67,22 @@ jobs:
|
|||||||
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
|
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Prepare Playwright artifact folders
|
|
||||||
run: |
|
|
||||||
rm -rf playwright-smoke/.artifacts
|
|
||||||
mkdir -p playwright-smoke/.artifacts/frontend
|
|
||||||
mkdir -p playwright-smoke/.artifacts/admin
|
|
||||||
|
|
||||||
- name: Run frontend UI regression suite
|
- name: Run frontend UI regression suite
|
||||||
id: ui_frontend
|
id: ui_frontend
|
||||||
working-directory: playwright-smoke
|
working-directory: playwright-smoke
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_USE_BUILT_APP: '1'
|
PLAYWRIGHT_USE_BUILT_APP: "1"
|
||||||
run: pnpm test:frontend
|
run: pnpm test:frontend
|
||||||
|
|
||||||
- name: Collect frontend Playwright artifacts
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ -d playwright-smoke/playwright-report ]; then
|
|
||||||
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report
|
|
||||||
fi
|
|
||||||
if [ -d playwright-smoke/test-results ]; then
|
|
||||||
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results
|
|
||||||
fi
|
|
||||||
rm -rf playwright-smoke/playwright-report playwright-smoke/test-results
|
|
||||||
|
|
||||||
- name: Run admin UI regression suite
|
- name: Run admin UI regression suite
|
||||||
id: ui_admin
|
id: ui_admin
|
||||||
working-directory: playwright-smoke
|
working-directory: playwright-smoke
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_USE_BUILT_APP: '1'
|
PLAYWRIGHT_USE_BUILT_APP: "1"
|
||||||
run: pnpm test:admin
|
run: pnpm test:admin
|
||||||
|
|
||||||
- name: Collect admin Playwright artifacts
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ -d playwright-smoke/playwright-report ]; then
|
|
||||||
cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report
|
|
||||||
fi
|
|
||||||
if [ -d playwright-smoke/test-results ]; then
|
|
||||||
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Summarize Playwright artifact paths
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "Gitea Actions 当前不支持 actions/upload-artifact@v4,改为直接输出产物目录:"
|
|
||||||
|
|
||||||
for path in \
|
|
||||||
"playwright-smoke/.artifacts/frontend/playwright-report" \
|
|
||||||
"playwright-smoke/.artifacts/frontend/test-results" \
|
|
||||||
"playwright-smoke/.artifacts/admin/playwright-report" \
|
|
||||||
"playwright-smoke/.artifacts/admin/test-results"
|
|
||||||
do
|
|
||||||
if [ -d "${path}" ]; then
|
|
||||||
echo "- ${path}"
|
|
||||||
find "${path}" -maxdepth 2 -type f | sort | head -n 20
|
|
||||||
else
|
|
||||||
echo "- ${path} (missing)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Mark workflow failed when any suite failed
|
- name: Mark workflow failed when any suite failed
|
||||||
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
|
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
|
||||||
run: exit 1
|
run: exit 1
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ pnpm dev
|
|||||||
```powershell
|
```powershell
|
||||||
cd backend
|
cd backend
|
||||||
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
||||||
cargo loco start 2>&1
|
cargo loco start --server-and-worker 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`。
|
||||||
|
|
||||||
### Docker(生产部署,使用 Gitea Package 镜像)
|
### Docker(生产部署,使用 Gitea Package 镜像)
|
||||||
|
|
||||||
补充部署分层与反代说明见:
|
补充部署分层与反代说明见:
|
||||||
|
|||||||
@@ -114,3 +114,14 @@ export function formatWorkerProgress(
|
|||||||
|
|
||||||
return progress.message ?? (details || null);
|
return progress.message ?? (details || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWorkerProgressPercent(
|
||||||
|
job: Pick<WorkerJobRecord, "result">,
|
||||||
|
): number | null {
|
||||||
|
const progress = getWorkerProgress(job);
|
||||||
|
if (typeof progress?.percent !== "number") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, Math.round(progress.percent)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Tags,
|
Tags,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'lucide-react'
|
} from "lucide-react";
|
||||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from 'sonner'
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { formatDateTime } from "@/lib/admin-format";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -26,9 +33,9 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from "@/components/ui/table";
|
||||||
import { adminApi, ApiError } from '@/lib/api'
|
import { adminApi, ApiError } from "@/lib/api";
|
||||||
import { buildFrontendUrl } from '@/lib/frontend-url'
|
import { buildFrontendUrl } from "@/lib/frontend-url";
|
||||||
import {
|
import {
|
||||||
formatCommentScope,
|
formatCommentScope,
|
||||||
formatPostStatus,
|
formatPostStatus,
|
||||||
@@ -37,8 +44,12 @@ import {
|
|||||||
formatPostVisibility,
|
formatPostVisibility,
|
||||||
formatReviewStatus,
|
formatReviewStatus,
|
||||||
formatReviewType,
|
formatReviewType,
|
||||||
} from '@/lib/admin-format'
|
} from "@/lib/admin-format";
|
||||||
import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
|
import type {
|
||||||
|
AdminAnalyticsResponse,
|
||||||
|
AdminDashboardResponse,
|
||||||
|
WorkerOverview,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
@@ -46,17 +57,21 @@ function StatCard({
|
|||||||
note,
|
note,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
value: number
|
value: number;
|
||||||
note: string
|
note: string;
|
||||||
icon: typeof Rss
|
icon: typeof Rss;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
<Card className="bg-gradient-to-br from-card via-card to-background/70">
|
||||||
<CardContent className="flex items-start justify-between pt-6">
|
<CardContent className="flex items-start justify-between pt-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
<div className="mt-3 text-3xl font-semibold tracking-tight">{value}</div>
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-3xl font-semibold tracking-tight">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">{note}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
@@ -64,75 +79,81 @@ function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAiSourceLabel(value: string) {
|
function formatAiSourceLabel(value: string) {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'chatgpt-search':
|
case "chatgpt-search":
|
||||||
return 'ChatGPT Search'
|
return "ChatGPT Search";
|
||||||
case 'perplexity':
|
case "perplexity":
|
||||||
return 'Perplexity'
|
return "Perplexity";
|
||||||
case 'copilot-bing':
|
case "copilot-bing":
|
||||||
return 'Copilot / Bing'
|
return "Copilot / Bing";
|
||||||
case 'gemini':
|
case "gemini":
|
||||||
return 'Gemini'
|
return "Gemini";
|
||||||
case 'claude':
|
case "claude":
|
||||||
return 'Claude'
|
return "Claude";
|
||||||
case 'google':
|
case "google":
|
||||||
return 'Google'
|
return "Google";
|
||||||
case 'duckduckgo':
|
case "duckduckgo":
|
||||||
return 'DuckDuckGo'
|
return "DuckDuckGo";
|
||||||
case 'kagi':
|
case "kagi":
|
||||||
return 'Kagi'
|
return "Kagi";
|
||||||
case 'direct':
|
case "direct":
|
||||||
return 'Direct'
|
return "Direct";
|
||||||
default:
|
default:
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [data, setData] = useState<AdminDashboardResponse | null>(null)
|
const [data, setData] = useState<AdminDashboardResponse | null>(null);
|
||||||
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(null)
|
const [workerOverview, setWorkerOverview] = useState<WorkerOverview | null>(
|
||||||
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(null)
|
null,
|
||||||
const [loading, setLoading] = useState(true)
|
);
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [analytics, setAnalytics] = useState<AdminAnalyticsResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const loadDashboard = useCallback(async (showToast = false) => {
|
const loadDashboard = useCallback(async (showToast = false) => {
|
||||||
try {
|
try {
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
setRefreshing(true)
|
setRefreshing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
|
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
|
||||||
adminApi.dashboard(),
|
adminApi.dashboard(),
|
||||||
adminApi.getWorkersOverview(),
|
adminApi.getWorkersOverview(),
|
||||||
adminApi.analytics(),
|
adminApi.analytics(),
|
||||||
])
|
]);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setData(next)
|
setData(next);
|
||||||
setWorkerOverview(nextWorkerOverview)
|
setWorkerOverview(nextWorkerOverview);
|
||||||
setAnalytics(nextAnalytics)
|
setAnalytics(nextAnalytics);
|
||||||
})
|
});
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.success('仪表盘已刷新。')
|
toast.success("仪表盘已刷新。");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.status === 401) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
|
toast.error(
|
||||||
|
error instanceof ApiError ? error.message : "无法加载仪表盘。",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
setRefreshing(false)
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadDashboard(false)
|
void loadDashboard(false);
|
||||||
}, [loadDashboard])
|
}, [loadDashboard]);
|
||||||
|
|
||||||
if (loading || !data || !workerOverview || !analytics) {
|
if (loading || !data || !workerOverview || !analytics) {
|
||||||
return (
|
return (
|
||||||
@@ -147,24 +168,24 @@ export function DashboardPage() {
|
|||||||
<Skeleton className="h-[420px] rounded-3xl" />
|
<Skeleton className="h-[420px] rounded-3xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
label: '文章总数',
|
label: "文章总数",
|
||||||
value: data.stats.total_posts,
|
value: data.stats.total_posts,
|
||||||
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
|
||||||
icon: Rss,
|
icon: Rss,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '待审核评论',
|
label: "待审核评论",
|
||||||
value: data.stats.pending_comments,
|
value: data.stats.pending_comments,
|
||||||
note: '等待审核处理',
|
note: "等待审核处理",
|
||||||
icon: MessageSquareWarning,
|
icon: MessageSquareWarning,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '发布待办',
|
label: "发布待办",
|
||||||
value:
|
value:
|
||||||
data.stats.draft_posts +
|
data.stats.draft_posts +
|
||||||
data.stats.scheduled_posts +
|
data.stats.scheduled_posts +
|
||||||
@@ -174,30 +195,32 @@ export function DashboardPage() {
|
|||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '分类数量',
|
label: "分类数量",
|
||||||
value: data.stats.total_categories,
|
value: data.stats.total_categories,
|
||||||
note: `当前共有 ${data.stats.total_tags} 个标签`,
|
note: `当前共有 ${data.stats.total_tags} 个标签`,
|
||||||
icon: FolderTree,
|
icon: FolderTree,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'AI 分块',
|
label: "AI 分块",
|
||||||
value: data.stats.ai_chunks,
|
value: data.stats.ai_chunks,
|
||||||
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
|
note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
|
||||||
icon: BrainCircuit,
|
icon: BrainCircuit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Worker 活动',
|
label: "Worker 活动",
|
||||||
value: workerOverview.active_jobs,
|
value: workerOverview.active_jobs,
|
||||||
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
|
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
const aiTrafficShare =
|
const aiTrafficShare =
|
||||||
analytics.content_overview.page_views_last_7d > 0
|
analytics.content_overview.page_views_last_7d > 0
|
||||||
? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100
|
? (analytics.ai_discovery_page_views_last_7d /
|
||||||
: 0
|
analytics.content_overview.page_views_last_7d) *
|
||||||
const topAiSource = analytics.ai_referrers_last_7d[0]
|
100
|
||||||
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length
|
: 0;
|
||||||
|
const topAiSource = analytics.ai_referrers_last_7d[0];
|
||||||
|
const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -207,14 +230,15 @@ export function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight">运营总览</h2>
|
<h2 className="text-3xl font-semibold tracking-tight">运营总览</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
这里汇总了最重要的发布、审核和 AI 信号,让日常运营在一个独立后台里完成闭环。
|
这里汇总了最重要的发布、审核和 AI
|
||||||
|
信号,让日常运营在一个独立后台里完成闭环。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
|
<a href={buildFrontendUrl("/ask")} target="_blank" rel="noreferrer">
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
打开 AI 问答
|
打开 AI 问答
|
||||||
</a>
|
</a>
|
||||||
@@ -225,7 +249,7 @@ export function DashboardPage() {
|
|||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
>
|
>
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
{refreshing ? '刷新中...' : '刷新'}
|
{refreshing ? "刷新中..." : "刷新"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,9 +265,7 @@ export function DashboardPage() {
|
|||||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>最近文章</CardTitle>
|
<CardTitle>最近文章</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>最近同步到前台的文章内容。</CardDescription>
|
||||||
最近同步到前台的文章内容。
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
<Badge variant="outline">{data.recent_posts.length} 条</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -265,9 +287,13 @@ export function DashboardPage() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-medium">{post.title}</span>
|
<span className="font-medium">{post.title}</span>
|
||||||
{post.pinned ? <Badge variant="success">置顶</Badge> : null}
|
{post.pinned ? (
|
||||||
|
<Badge variant="success">置顶</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
{post.slug}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="uppercase text-muted-foreground">
|
<TableCell className="uppercase text-muted-foreground">
|
||||||
@@ -275,12 +301,18 @@ export function DashboardPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
|
<Badge variant="outline">
|
||||||
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
|
{formatPostStatus(post.status)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{formatPostVisibility(post.visibility)}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{post.category}</TableCell>
|
<TableCell>{post.category}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
|
<TableCell className="text-muted-foreground">
|
||||||
|
{post.created_at}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -291,19 +323,19 @@ export function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>站点状态</CardTitle>
|
<CardTitle>站点状态</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>快速查看前台站点与 AI 索引状态。</CardDescription>
|
||||||
快速查看前台站点与 AI 索引状态。
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{data.site.site_name}</p>
|
<p className="text-sm font-medium">{data.site.site_name}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{data.site.site_url}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
|
<Badge variant={data.site.ai_enabled ? "success" : "warning"}>
|
||||||
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
|
{data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +346,9 @@ export function DashboardPage() {
|
|||||||
评测
|
评测
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-end gap-2">
|
<div className="mt-3 flex items-end gap-2">
|
||||||
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
|
<span className="text-3xl font-semibold">
|
||||||
|
{data.stats.total_reviews}
|
||||||
|
</span>
|
||||||
<Star className="mb-1 h-4 w-4 text-amber-500" />
|
<Star className="mb-1 h-4 w-4 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,7 +357,9 @@ export function DashboardPage() {
|
|||||||
友链
|
友链
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-end gap-2">
|
<div className="mt-3 flex items-end gap-2">
|
||||||
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
|
<span className="text-3xl font-semibold">
|
||||||
|
{data.stats.total_links}
|
||||||
|
</span>
|
||||||
<Tags className="mb-1 h-4 w-4 text-primary" />
|
<Tags className="mb-1 h-4 w-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,25 +371,35 @@ export function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
|
<p className="text-2xl font-semibold">
|
||||||
|
{data.stats.draft_posts}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">草稿</p>
|
<p className="text-xs text-muted-foreground">草稿</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
|
<p className="text-2xl font-semibold">
|
||||||
|
{data.stats.scheduled_posts}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">定时发布</p>
|
<p className="text-xs text-muted-foreground">定时发布</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
|
<p className="text-2xl font-semibold">
|
||||||
|
{data.stats.offline_posts}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">手动下线</p>
|
<p className="text-xs text-muted-foreground">手动下线</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
|
<p className="text-2xl font-semibold">
|
||||||
|
{data.stats.expired_posts}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">自动过期</p>
|
<p className="text-xs text-muted-foreground">自动过期</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<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.private_posts}</Badge>
|
||||||
<Badge variant="outline">不公开 {data.stats.unlisted_posts}</Badge>
|
<Badge variant="outline">
|
||||||
|
不公开 {data.stats.unlisted_posts}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -362,7 +408,9 @@ export function DashboardPage() {
|
|||||||
最近一次 AI 索引
|
最近一次 AI 索引
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
|
{data.site.ai_last_indexed_at
|
||||||
|
? formatDateTime(data.site.ai_last_indexed_at)
|
||||||
|
: "站点还没有建立过索引。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -372,9 +420,13 @@ export function DashboardPage() {
|
|||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
GEO / AI 来源概览
|
GEO / AI 来源概览
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{analytics.ai_discovery_page_views_last_7d}</p>
|
<p className="mt-3 text-3xl font-semibold">
|
||||||
|
{analytics.ai_discovery_page_views_last_7d}
|
||||||
|
</p>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||||
最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。
|
最近 7 天来自 ChatGPT
|
||||||
|
Search、Perplexity、Copilot/Bing、Gemini、Claude
|
||||||
|
的页面访问。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
@@ -384,23 +436,41 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">访问占比</div>
|
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<div className="mt-2 text-2xl font-semibold">{Math.round(aiTrafficShare)}%</div>
|
访问占比
|
||||||
<div className="mt-1 text-xs text-muted-foreground">基于近 7 天全部 page_view</div>
|
</div>
|
||||||
</div>
|
<div className="mt-2 text-2xl font-semibold">
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
{Math.round(aiTrafficShare)}%
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">最高来源</div>
|
|
||||||
<div className="mt-2 text-base font-semibold">
|
|
||||||
{topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
{topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'}
|
基于近 7 天全部 page_view
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">已识别来源</div>
|
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<div className="mt-2 text-2xl font-semibold">{totalAiSourceBuckets}</div>
|
最高来源
|
||||||
<div className="mt-1 text-xs text-muted-foreground">当前已聚合的 AI 搜索渠道</div>
|
</div>
|
||||||
|
<div className="mt-2 text-base font-semibold">
|
||||||
|
{topAiSource
|
||||||
|
? formatAiSourceLabel(topAiSource.referrer)
|
||||||
|
: "暂无"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{topAiSource
|
||||||
|
? `${topAiSource.count} 次访问`
|
||||||
|
: "等待来源数据"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
已识别来源
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold">
|
||||||
|
{totalAiSourceBuckets}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
当前已聚合的 AI 搜索渠道
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,15 +478,24 @@ export function DashboardPage() {
|
|||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
|
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
|
||||||
const width = `${Math.max(
|
const width = `${Math.max(
|
||||||
(item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100,
|
(item.count /
|
||||||
|
Math.max(
|
||||||
|
analytics.ai_discovery_page_views_last_7d,
|
||||||
|
1,
|
||||||
|
)) *
|
||||||
|
100,
|
||||||
8,
|
8,
|
||||||
)}%`
|
)}%`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.referrer} className="space-y-1.5">
|
<div key={item.referrer} className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span className="font-medium">{formatAiSourceLabel(item.referrer)}</span>
|
<span className="font-medium">
|
||||||
<span className="text-muted-foreground">{item.count}</span>
|
{formatAiSourceLabel(item.referrer)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||||
<div
|
<div
|
||||||
@@ -425,11 +504,13 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-4 text-sm text-muted-foreground">最近 7 天还没有识别到 AI 搜索来源流量。</p>
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
|
最近 7 天还没有识别到 AI 搜索来源流量。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,12 +521,17 @@ export function DashboardPage() {
|
|||||||
Worker 健康
|
Worker 健康
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
当前排队 {workerOverview.queued}、运行 {workerOverview.running}、失败 {workerOverview.failed}。
|
当前排队 {workerOverview.queued}、运行{" "}
|
||||||
|
{workerOverview.running}、失败 {workerOverview.failed}。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link
|
<Link
|
||||||
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'}
|
to={
|
||||||
|
workerOverview.failed > 0
|
||||||
|
? "/workers?status=failed"
|
||||||
|
: "/workers"
|
||||||
|
}
|
||||||
data-testid="dashboard-worker-open"
|
data-testid="dashboard-worker-open"
|
||||||
>
|
>
|
||||||
查看队列
|
查看队列
|
||||||
@@ -459,24 +545,36 @@ export function DashboardPage() {
|
|||||||
data-testid="dashboard-worker-card-queued"
|
data-testid="dashboard-worker-card-queued"
|
||||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div>
|
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div>
|
Queued
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||||
|
{workerOverview.queued}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/workers?status=running"
|
to="/workers?status=running"
|
||||||
data-testid="dashboard-worker-card-running"
|
data-testid="dashboard-worker-card-running"
|
||||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div>
|
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div>
|
Running
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||||
|
{workerOverview.running}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/workers?status=failed"
|
to="/workers?status=failed"
|
||||||
data-testid="dashboard-worker-card-failed"
|
data-testid="dashboard-worker-card-failed"
|
||||||
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
className="rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div>
|
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div>
|
Failed
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold text-foreground">
|
||||||
|
{workerOverview.failed}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -489,11 +587,17 @@ export function DashboardPage() {
|
|||||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 px-4 py-3 text-sm transition hover:border-primary/30 hover:bg-primary/5"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">{item.label}</div>
|
<div className="font-medium text-foreground">
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{item.worker_name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs text-muted-foreground">
|
<div className="text-right text-xs text-muted-foreground">
|
||||||
<div>Q {item.queued} · R {item.running}</div>
|
<div>
|
||||||
|
Q {item.queued} · R {item.running}
|
||||||
|
</div>
|
||||||
<div>ERR {item.failed}</div>
|
<div>ERR {item.failed}</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -510,11 +614,11 @@ export function DashboardPage() {
|
|||||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>待审核评论</CardTitle>
|
<CardTitle>待审核评论</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>在当前管理端直接查看审核队列。</CardDescription>
|
||||||
在当前管理端直接查看审核队列。
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="warning">{data.pending_comments.length} 条待处理</Badge>
|
<Badge variant="warning">
|
||||||
|
{data.pending_comments.length} 条待处理
|
||||||
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -543,7 +647,9 @@ export function DashboardPage() {
|
|||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
{comment.post_slug}
|
{comment.post_slug}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{comment.created_at}</TableCell>
|
<TableCell className="text-muted-foreground">
|
||||||
|
{comment.created_at}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -556,11 +662,11 @@ export function DashboardPage() {
|
|||||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>待审核友链</CardTitle>
|
<CardTitle>待审核友链</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>等待审核和互链确认的申请。</CardDescription>
|
||||||
等待审核和互链确认的申请。
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="warning">{data.pending_friend_links.length} 条待处理</Badge>
|
<Badge variant="warning">
|
||||||
|
{data.pending_friend_links.length} 条待处理
|
||||||
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{data.pending_friend_links.map((link) => (
|
{data.pending_friend_links.map((link) => (
|
||||||
@@ -591,9 +697,7 @@ export function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>最近评测</CardTitle>
|
<CardTitle>最近评测</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>最近同步到前台评测页的内容。</CardDescription>
|
||||||
最近同步到前台评测页的内容。
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{data.recent_reviews.map((review) => (
|
{data.recent_reviews.map((review) => (
|
||||||
@@ -604,11 +708,14 @@ export function DashboardPage() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">{review.title}</p>
|
<p className="font-medium">{review.title}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
|
{formatReviewType(review.review_type)} ·{" "}
|
||||||
|
{formatReviewStatus(review.status)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-semibold">{review.rating}/5</div>
|
<div className="text-lg font-semibold">
|
||||||
|
{review.rating}/5
|
||||||
|
</div>
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{review.review_date}
|
{review.review_date}
|
||||||
</p>
|
</p>
|
||||||
@@ -620,5 +727,5 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ import { Select } from "@/components/ui/select";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { adminApi, ApiError } from "@/lib/api";
|
import { adminApi, ApiError } from "@/lib/api";
|
||||||
import { formatWorkerProgress } from "@/lib/worker-progress";
|
import { formatDateTime } from "@/lib/admin-format";
|
||||||
|
import {
|
||||||
|
formatWorkerProgress,
|
||||||
|
getWorkerProgressPercent,
|
||||||
|
} from "@/lib/worker-progress";
|
||||||
import type {
|
import type {
|
||||||
AdminSiteSettingsResponse,
|
AdminSiteSettingsResponse,
|
||||||
AiProviderConfig,
|
AiProviderConfig,
|
||||||
@@ -2119,7 +2123,9 @@ export function SiteSettingsPage() {
|
|||||||
最近索引时间
|
最近索引时间
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
{form.ai_last_indexed_at ?? "索引尚未建立。"}
|
{form.ai_last_indexed_at
|
||||||
|
? formatDateTime(form.ai_last_indexed_at)
|
||||||
|
: "索引尚未建立。"}
|
||||||
</p>
|
</p>
|
||||||
{reindexJobId ? (
|
{reindexJobId ? (
|
||||||
<p className="mt-3 text-xs leading-6 text-muted-foreground">
|
<p className="mt-3 text-xs leading-6 text-muted-foreground">
|
||||||
@@ -2133,6 +2139,18 @@ export function SiteSettingsPage() {
|
|||||||
"任务已经开始,正在等待下一次进度更新。"}
|
"任务已经开始,正在等待下一次进度更新。"}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{reindexJobResult &&
|
||||||
|
getWorkerProgressPercent({ result: reindexJobResult }) !==
|
||||||
|
null ? (
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-border/70">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${getWorkerProgressPercent({ result: reindexJobResult })}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,15 @@ Loco.rs backend,当前仅保留 API 与后台鉴权相关逻辑,不再提供
|
|||||||
## 本地启动
|
## 本地启动
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cargo loco start
|
cargo loco start --server-and-worker
|
||||||
```
|
```
|
||||||
|
|
||||||
默认本地监听:
|
默认本地监听:
|
||||||
|
|
||||||
- `http://localhost:5150`
|
- `http://localhost:5150`
|
||||||
|
|
||||||
|
如果只启动 `cargo loco start` 而没有 `worker`,浏览器推送、异步通知、失败重试这类 Redis 队列任务会入队但没人消费。
|
||||||
|
|
||||||
## 当前职责
|
## 当前职责
|
||||||
|
|
||||||
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API
|
- 文章 / 分类 / 标签 / 评论 / 友链 / 评测 API
|
||||||
|
|||||||
@@ -2537,6 +2537,18 @@ async fn update_reindex_job_progress(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn stop_reindex_if_cancel_requested(ctx: &AppContext, job_id: Option<i32>) -> Result<()> {
|
||||||
|
let Some(job_id) = job_id else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if worker_jobs::cancel_job_if_requested(ctx, job_id, "job cancelled during reindex").await? {
|
||||||
|
return Err(Error::BadRequest("job cancelled".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_runtime_settings(
|
async fn load_runtime_settings(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
require_enabled: bool,
|
require_enabled: bool,
|
||||||
@@ -2729,6 +2741,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
|
|||||||
batch_size,
|
batch_size,
|
||||||
);
|
);
|
||||||
update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
|
update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
|
||||||
|
stop_reindex_if_cancel_requested(ctx, job_id).await?;
|
||||||
let txn = ctx.db.begin().await?;
|
let txn = ctx.db.begin().await?;
|
||||||
|
|
||||||
txn.execute(Statement::from_string(
|
txn.execute(Statement::from_string(
|
||||||
@@ -2740,6 +2753,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
|
|||||||
let mut processed_chunks = 0usize;
|
let mut processed_chunks = 0usize;
|
||||||
|
|
||||||
for chunk_batch in chunk_drafts.chunks(batch_size) {
|
for chunk_batch in chunk_drafts.chunks(batch_size) {
|
||||||
|
stop_reindex_if_cancel_requested(ctx, job_id).await?;
|
||||||
let embeddings = embed_texts_locally_with_batch_size(
|
let embeddings = embed_texts_locally_with_batch_size(
|
||||||
chunk_batch
|
chunk_batch
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2799,6 +2813,7 @@ pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIn
|
|||||||
update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
|
update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stop_reindex_if_cancel_requested(ctx, job_id).await?;
|
||||||
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
|
let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ pub const DELIVERY_STATUS_RETRY_PENDING: &str = "retry_pending";
|
|||||||
pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted";
|
pub const DELIVERY_STATUS_EXHAUSTED: &str = "exhausted";
|
||||||
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped";
|
pub const DELIVERY_STATUS_SKIPPED: &str = "skipped";
|
||||||
|
|
||||||
const MAX_DELIVERY_ATTEMPTS: i32 = 5;
|
const WEB_PUSH_TITLE_MAX_CHARS: usize = 72;
|
||||||
|
const WEB_PUSH_BODY_MAX_CHARS: usize = 160;
|
||||||
|
const WEB_PUSH_MAX_PAYLOAD_BYTES: usize = 2800;
|
||||||
|
const WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD: i32 = 2;
|
||||||
|
const WEB_PUSH_AUTO_PAUSE_NOTE: &str =
|
||||||
|
"浏览器推送订阅连续投递失败,系统已自动暂停。请在浏览器里重新开启提醒。";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct DigestDispatchSummary {
|
pub struct DigestDispatchSummary {
|
||||||
@@ -259,6 +264,97 @@ fn merge_browser_push_metadata(
|
|||||||
Value::Object(object)
|
Value::Object(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
|
||||||
|
let note = note.trim();
|
||||||
|
let mut lines = existing
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if !note.is_empty() && !lines.iter().any(|line| line == note) {
|
||||||
|
lines.push(note.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(lines.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_subscription_note(existing: Option<&str>, note: &str) -> Option<String> {
|
||||||
|
let note = note.trim();
|
||||||
|
let lines = existing
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && *line != note)
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(lines.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_push_error_looks_terminal(error_text: &str) -> bool {
|
||||||
|
let normalized = error_text.trim().to_ascii_lowercase();
|
||||||
|
|
||||||
|
normalized.contains("endpoint_host=fcm.googleapis.com")
|
||||||
|
&& normalized.contains("unspecified error")
|
||||||
|
|| normalized.contains("410")
|
||||||
|
|| normalized.contains("404")
|
||||||
|
|| normalized.contains("gone")
|
||||||
|
|| normalized.contains("not found")
|
||||||
|
|| normalized.contains("expired")
|
||||||
|
|| normalized.contains("unsubscribed")
|
||||||
|
|| normalized.contains("invalid subscription")
|
||||||
|
|| normalized.contains("push subscription")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_auto_pause_failed_web_push_subscription(
|
||||||
|
failure_count_after_error: i32,
|
||||||
|
error_text: &str,
|
||||||
|
) -> bool {
|
||||||
|
failure_count_after_error >= WEB_PUSH_AUTO_PAUSE_FAILURE_THRESHOLD
|
||||||
|
|| web_push_error_looks_terminal(error_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maybe_pause_failed_web_push_subscription(
|
||||||
|
ctx: &AppContext,
|
||||||
|
subscription: Option<&subscriptions::Model>,
|
||||||
|
error_text: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(subscription) = subscription else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if subscription.channel_type != CHANNEL_WEB_PUSH
|
||||||
|
|| normalize_status(&subscription.status) != STATUS_ACTIVE
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let failure_count_after_error = subscription.failure_count.unwrap_or(0) + 1;
|
||||||
|
if !should_auto_pause_failed_web_push_subscription(failure_count_after_error, error_text) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = subscription.clone().into_active_model();
|
||||||
|
active.status = Set(STATUS_PAUSED.to_string());
|
||||||
|
active.notes = Set(merge_subscription_note(
|
||||||
|
subscription.notes.as_deref(),
|
||||||
|
WEB_PUSH_AUTO_PAUSE_NOTE,
|
||||||
|
));
|
||||||
|
let _ = active.update(&ctx.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
|
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
@@ -321,16 +417,6 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
|
|||||||
values
|
values
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delivery_retry_delay(attempts: i32) -> Duration {
|
|
||||||
match attempts {
|
|
||||||
0 | 1 => Duration::minutes(1),
|
|
||||||
2 => Duration::minutes(5),
|
|
||||||
3 => Duration::minutes(15),
|
|
||||||
4 => Duration::minutes(60),
|
|
||||||
_ => Duration::hours(6),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
|
fn effective_period(period: &str) -> (&'static str, i64, &'static str) {
|
||||||
match period.trim().to_ascii_lowercase().as_str() {
|
match period.trim().to_ascii_lowercase().as_str() {
|
||||||
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY),
|
"monthly" | "month" | "30d" => ("monthly", 30, EVENT_DIGEST_MONTHLY),
|
||||||
@@ -680,6 +766,12 @@ pub async fn create_public_web_push_subscription(
|
|||||||
active.status = Set(STATUS_ACTIVE.to_string());
|
active.status = Set(STATUS_ACTIVE.to_string());
|
||||||
active.confirm_token = Set(None);
|
active.confirm_token = Set(None);
|
||||||
active.verified_at = Set(Some(Utc::now().to_rfc3339()));
|
active.verified_at = Set(Some(Utc::now().to_rfc3339()));
|
||||||
|
active.failure_count = Set(Some(0));
|
||||||
|
active.last_delivery_status = Set(None);
|
||||||
|
active.notes = Set(remove_subscription_note(
|
||||||
|
existing.notes.as_deref(),
|
||||||
|
WEB_PUSH_AUTO_PAUSE_NOTE,
|
||||||
|
));
|
||||||
active.metadata = Set(Some(merge_browser_push_metadata(
|
active.metadata = Set(Some(merge_browser_push_metadata(
|
||||||
existing.metadata.as_ref(),
|
existing.metadata.as_ref(),
|
||||||
metadata,
|
metadata,
|
||||||
@@ -1066,26 +1158,47 @@ fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
|
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
|
||||||
let body = truncate_chars(&collapse_whitespace(&message.text), 220);
|
let title = truncate_chars(
|
||||||
|
&collapse_whitespace(&message.subject),
|
||||||
|
WEB_PUSH_TITLE_MAX_CHARS,
|
||||||
|
);
|
||||||
|
let body = truncate_chars(&collapse_whitespace(&message.text), WEB_PUSH_BODY_MAX_CHARS);
|
||||||
|
let url = web_push_target_url(message);
|
||||||
|
let event_type = message
|
||||||
|
.payload
|
||||||
|
.get("event_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("subscription");
|
||||||
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"title": message.subject,
|
"title": title,
|
||||||
"body": body,
|
"body": body,
|
||||||
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
|
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
|
||||||
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
|
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
|
||||||
"url": web_push_target_url(message),
|
"url": url.clone(),
|
||||||
"tag": message
|
"tag": event_type,
|
||||||
.payload
|
|
||||||
.get("event_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("subscription"),
|
|
||||||
"data": {
|
"data": {
|
||||||
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null),
|
"url": url,
|
||||||
"payload": message.payload,
|
"event_type": event_type,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_web_push_payload(message: &QueuedDeliveryPayload) -> Result<Vec<u8>> {
|
||||||
|
let payload = build_web_push_payload(message);
|
||||||
|
let encoded = serde_json::to_vec(&payload)?;
|
||||||
|
|
||||||
|
if encoded.len() > WEB_PUSH_MAX_PAYLOAD_BYTES {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"web push payload too large: {} bytes exceeds safe limit {} bytes",
|
||||||
|
encoded.len(),
|
||||||
|
WEB_PUSH_MAX_PAYLOAD_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
async fn deliver_via_channel(
|
async fn deliver_via_channel(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
channel_type: &str,
|
channel_type: &str,
|
||||||
@@ -1126,7 +1239,7 @@ async fn deliver_via_channel(
|
|||||||
CHANNEL_WEB_PUSH => {
|
CHANNEL_WEB_PUSH => {
|
||||||
let settings = crate::controllers::site_settings::load_current(ctx).await?;
|
let settings = crate::controllers::site_settings::load_current(ctx).await?;
|
||||||
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
|
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
|
||||||
let payload = serde_json::to_vec(&build_web_push_payload(message))?;
|
let payload = encode_web_push_payload(message)?;
|
||||||
web_push_service::send_payload(
|
web_push_service::send_payload(
|
||||||
&settings,
|
&settings,
|
||||||
&subscription_info,
|
&subscription_info,
|
||||||
@@ -1275,24 +1388,25 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS)
|
let error_text = error.to_string();
|
||||||
.then(|| (Utc::now() + delivery_retry_delay(attempts)).to_rfc3339());
|
|
||||||
let status = if next_retry_at.is_some() {
|
|
||||||
DELIVERY_STATUS_RETRY_PENDING
|
|
||||||
} else {
|
|
||||||
DELIVERY_STATUS_EXHAUSTED
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut active = delivery.into_active_model();
|
let mut active = delivery.into_active_model();
|
||||||
active.status = Set(status.to_string());
|
active.status = Set(DELIVERY_STATUS_EXHAUSTED.to_string());
|
||||||
active.provider = Set(Some(provider_name(&delivery_channel_type).to_string()));
|
active.provider = Set(Some(provider_name(&delivery_channel_type).to_string()));
|
||||||
active.response_text = Set(Some(error.to_string()));
|
active.response_text = Set(Some(error_text.clone()));
|
||||||
active.attempts_count = Set(attempts);
|
active.attempts_count = Set(attempts);
|
||||||
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339()));
|
active.last_attempt_at = Set(Some(Utc::now().to_rfc3339()));
|
||||||
active.next_retry_at = Set(next_retry_at);
|
active.next_retry_at = Set(None);
|
||||||
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
|
active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
|
||||||
let _ = active.update(&ctx.db).await?;
|
let _ = active.update(&ctx.db).await?;
|
||||||
update_subscription_delivery_state(ctx, subscription_id, status, false).await?;
|
update_subscription_delivery_state(
|
||||||
|
ctx,
|
||||||
|
subscription_id,
|
||||||
|
DELIVERY_STATUS_EXHAUSTED,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
maybe_pause_failed_web_push_subscription(ctx, subscription.as_ref(), &error_text)
|
||||||
|
.await?;
|
||||||
Err(error)?;
|
Err(error)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
use reqwest::Url;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use web_push::{
|
use web_push::{
|
||||||
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
|
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
|
||||||
@@ -46,17 +47,30 @@ pub fn vapid_subject(settings: &site_settings::Model) -> Option<String> {
|
|||||||
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
|
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_vapid_subject(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim();
|
||||||
|
if trimmed.starts_with("mailto:") || trimmed.starts_with("https://") {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
|
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
|
||||||
vapid_subject(settings)
|
normalize_vapid_subject(vapid_subject(settings))
|
||||||
.or_else(|| {
|
.or_else(|| normalize_vapid_subject(site_url.map(ToString::to_string)))
|
||||||
site_url
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "mailto:noreply@example.com".to_string())
|
.unwrap_or_else(|| "mailto:noreply@example.com".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subscription_endpoint_host(subscription_info: &SubscriptionInfo) -> String {
|
||||||
|
Url::parse(&subscription_info.endpoint)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(ToString::to_string))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn public_key_configured(settings: &site_settings::Model) -> bool {
|
pub fn public_key_configured(settings: &site_settings::Model) -> bool {
|
||||||
public_key(settings).is_some()
|
public_key(settings).is_some()
|
||||||
}
|
}
|
||||||
@@ -90,10 +104,12 @@ pub async fn send_payload(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let private_key = private_key(settings)
|
let private_key = private_key(settings)
|
||||||
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
|
||||||
|
let subject = effective_vapid_subject(settings, site_url);
|
||||||
|
let endpoint_host = subscription_endpoint_host(subscription_info);
|
||||||
|
|
||||||
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
|
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
|
||||||
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
|
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
|
||||||
signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url));
|
signature_builder.add_claim("sub", subject.clone());
|
||||||
let signature = signature_builder
|
let signature = signature_builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
|
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
|
||||||
@@ -111,10 +127,11 @@ pub async fn send_payload(
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
|
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
|
||||||
|
|
||||||
client
|
client.send(message).await.map_err(|error| {
|
||||||
.send(message)
|
Error::BadRequest(format!(
|
||||||
.await
|
"web push send failed: {error}; vapid subject={subject}; endpoint_host={endpoint_host}"
|
||||||
.map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?;
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -614,6 +614,20 @@ pub async fn update_job_result(ctx: &AppContext, id: i32, result: Value) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_job_if_requested(ctx: &AppContext, id: i32, reason: &str) -> Result<bool> {
|
||||||
|
let item = find_job(ctx, id).await?;
|
||||||
|
if item.status == JOB_STATUS_CANCELLED {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.cancel_requested {
|
||||||
|
finish_job_cancelled(ctx, id, Some(reason.to_string())).await?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
|
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
|
||||||
let item = find_job(ctx, id).await?;
|
let item = find_job(ctx, id).await?;
|
||||||
let mut active = item.into_active_model();
|
let mut active = item.into_active_model();
|
||||||
@@ -708,6 +722,13 @@ pub async fn queue_notification_delivery_job(
|
|||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(Error::NotFound)?;
|
.ok_or(Error::NotFound)?;
|
||||||
|
let mut delivery_active = delivery.clone().into_active_model();
|
||||||
|
delivery_active.status = Set(subscriptions::DELIVERY_STATUS_QUEUED.to_string());
|
||||||
|
delivery_active.response_text = Set(None);
|
||||||
|
delivery_active.next_retry_at = Set(None);
|
||||||
|
delivery_active.delivered_at = Set(None);
|
||||||
|
delivery_active.attempts_count = Set(0);
|
||||||
|
let delivery = delivery_active.update(&ctx.db).await?;
|
||||||
|
|
||||||
let base_args = NotificationDeliveryWorkerArgs {
|
let base_args = NotificationDeliveryWorkerArgs {
|
||||||
delivery_id,
|
delivery_id,
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ impl BackgroundWorker<AiReindexWorkerArgs> for AiReindexWorker {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
if worker_jobs::cancel_job_if_requested(
|
||||||
|
&self.ctx,
|
||||||
|
job_id,
|
||||||
|
"job cancelled during reindex",
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
|
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
|
||||||
Err(error)
|
Err(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
|
|||||||
Self { ctx: ctx.clone() }
|
Self { ctx: ctx.clone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tags() -> Vec<String> {
|
|
||||||
vec!["notifications".to_string()]
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
|
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
|
||||||
if let Some(job_id) = args.job_id {
|
if let Some(job_id) = args.job_id {
|
||||||
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
|
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
|
||||||
|
|||||||
4
dev.ps1
4
dev.ps1
@@ -105,8 +105,8 @@ function Start-Backend {
|
|||||||
-Run {
|
-Run {
|
||||||
$env:DATABASE_URL = $DatabaseUrl
|
$env:DATABASE_URL = $DatabaseUrl
|
||||||
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
|
Write-Host "[backend] DATABASE_URL set to $DatabaseUrl" -ForegroundColor Cyan
|
||||||
Write-Host "[backend] Starting Loco.rs server..." -ForegroundColor Green
|
Write-Host "[backend] Starting Loco.rs server + worker..." -ForegroundColor Green
|
||||||
cargo loco start 2>&1
|
cargo loco start --server-and-worker 2>&1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
self.addEventListener('push', (event) => {
|
self.addEventListener("push", (event) => {
|
||||||
const payload = (() => {
|
const payload = (() => {
|
||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
return {};
|
return {};
|
||||||
@@ -13,39 +13,59 @@ self.addEventListener('push', (event) => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const title = payload.title || '订阅更新';
|
const notifyClients = self.clients
|
||||||
const url = payload.url || '/';
|
.matchAll({ type: "window", includeUncontrolled: true })
|
||||||
|
.then((clients) =>
|
||||||
|
Promise.all(
|
||||||
|
clients.map((client) =>
|
||||||
|
client.postMessage({
|
||||||
|
type: "termi:web-push-received",
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = payload.title || "订阅更新";
|
||||||
|
const url = payload.url || "/";
|
||||||
const options = {
|
const options = {
|
||||||
body: payload.body || '',
|
body: payload.body || "",
|
||||||
icon: payload.icon || '/favicon.svg',
|
icon: payload.icon || "/favicon.svg",
|
||||||
badge: payload.badge || '/favicon.ico',
|
badge: payload.badge || "/favicon.ico",
|
||||||
tag: payload.tag || 'termi-subscription',
|
tag: payload.tag || "termi-subscription",
|
||||||
data: {
|
data: {
|
||||||
url,
|
url,
|
||||||
...(payload.data || {}),
|
...(payload.data || {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(title, options));
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const targetUrl = event.notification?.data?.url || '/';
|
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
Promise.all([
|
||||||
for (const client of clients) {
|
self.registration.showNotification(title, options),
|
||||||
if ('focus' in client && client.url === targetUrl) {
|
notifyClients,
|
||||||
return client.focus();
|
]),
|
||||||
}
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (self.clients.openWindow) {
|
self.addEventListener("notificationclick", (event) => {
|
||||||
return self.clients.openWindow(targetUrl);
|
event.notification.close();
|
||||||
}
|
const targetUrl = event.notification?.data?.url || "/";
|
||||||
|
|
||||||
return undefined;
|
event.waitUntil(
|
||||||
}),
|
self.clients
|
||||||
|
.matchAll({ type: "window", includeUncontrolled: true })
|
||||||
|
.then((clients) => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if ("focus" in client && client.url === targetUrl) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
|||||||
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||||
import {
|
import {
|
||||||
ensureBrowserPushSubscription,
|
ensureBrowserPushSubscription,
|
||||||
getBrowserPushSubscription,
|
getBrowserPushSubscriptionState,
|
||||||
supportsBrowserPush,
|
supportsBrowserPush,
|
||||||
} from '../lib/utils/web-push';
|
} from '../lib/utils/web-push';
|
||||||
|
|
||||||
@@ -505,6 +505,14 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forgetSubmitted = () => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(SUBSCRIBED_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetStatus = () => {
|
const resetStatus = () => {
|
||||||
delete status.dataset.state;
|
delete status.dataset.state;
|
||||||
status.textContent = defaultStatus;
|
status.textContent = defaultStatus;
|
||||||
@@ -811,7 +819,19 @@ const webPushAvailable = Boolean(webPushPublicKey);
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await getBrowserPushSubscription();
|
const { subscription, stale } = await getBrowserPushSubscriptionState(
|
||||||
|
browserPushPublicKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stale) {
|
||||||
|
forgetSubmitted();
|
||||||
|
setBrowserAvailability({
|
||||||
|
selectable: true,
|
||||||
|
note: '检测到提醒配置已更新,需要重新开启一次提醒。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
rememberSubmitted();
|
rememberSubmitted();
|
||||||
setBrowserAvailability({
|
setBrowserAvailability({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
|
const SERVICE_WORKER_URL = "/termi-web-push-sw.js";
|
||||||
|
|
||||||
export type BrowserPushSubscriptionPayload = {
|
export type BrowserPushSubscriptionPayload = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -9,21 +9,26 @@ export type BrowserPushSubscriptionPayload = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BrowserPushSubscriptionState = {
|
||||||
|
subscription: PushSubscription | null;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function ensureBrowserSupport() {
|
function ensureBrowserSupport() {
|
||||||
if (
|
if (
|
||||||
typeof window === 'undefined' ||
|
typeof window === "undefined" ||
|
||||||
!('Notification' in window) ||
|
!("Notification" in window) ||
|
||||||
!('serviceWorker' in navigator) ||
|
!("serviceWorker" in navigator) ||
|
||||||
!('PushManager' in window)
|
!("PushManager" in window)
|
||||||
) {
|
) {
|
||||||
throw new Error('当前浏览器不支持 Web Push。');
|
throw new Error("当前浏览器不支持 Web Push。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String: string) {
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
const normalized = base64String.trim();
|
const normalized = base64String.trim();
|
||||||
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||||
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = (normalized + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
const binary = window.atob(base64);
|
const binary = window.atob(base64);
|
||||||
const output = new Uint8Array(binary.length);
|
const output = new Uint8Array(binary.length);
|
||||||
|
|
||||||
@@ -34,22 +39,66 @@ function urlBase64ToUint8Array(base64String: string) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toUint8Array(value: BufferSource | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArraysEqual(left: Uint8Array | null, right: Uint8Array | null) {
|
||||||
|
if (!left || !right || left.byteLength !== right.byteLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < left.byteLength; index += 1) {
|
||||||
|
if (left[index] !== right[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionMatchesPublicKey(
|
||||||
|
subscription: PushSubscription,
|
||||||
|
publicKeyBytes: Uint8Array | null,
|
||||||
|
) {
|
||||||
|
if (!publicKeyBytes) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKey = toUint8Array(
|
||||||
|
subscription.options?.applicationServerKey ?? null,
|
||||||
|
);
|
||||||
|
return uint8ArraysEqual(currentKey, publicKeyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
async function getRegistration() {
|
async function getRegistration() {
|
||||||
ensureBrowserSupport();
|
ensureBrowserSupport();
|
||||||
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
|
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: "/" });
|
||||||
return navigator.serviceWorker.ready;
|
return navigator.serviceWorker.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSubscription(
|
function normalizeSubscription(
|
||||||
subscription: PushSubscription | PushSubscriptionJSON,
|
subscription: PushSubscription | PushSubscriptionJSON,
|
||||||
): BrowserPushSubscriptionPayload {
|
): BrowserPushSubscriptionPayload {
|
||||||
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
|
const json = "toJSON" in subscription ? subscription.toJSON() : subscription;
|
||||||
const endpoint = json.endpoint?.trim() || '';
|
const endpoint = json.endpoint?.trim() || "";
|
||||||
const auth = json.keys?.auth?.trim() || '';
|
const auth = json.keys?.auth?.trim() || "";
|
||||||
const p256dh = json.keys?.p256dh?.trim() || '';
|
const p256dh = json.keys?.p256dh?.trim() || "";
|
||||||
|
|
||||||
if (!endpoint || !auth || !p256dh) {
|
if (!endpoint || !auth || !p256dh) {
|
||||||
throw new Error('浏览器返回的 PushSubscription 不完整。');
|
throw new Error("浏览器返回的 PushSubscription 不完整。");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -64,20 +113,53 @@ function normalizeSubscription(
|
|||||||
|
|
||||||
export function supportsBrowserPush() {
|
export function supportsBrowserPush() {
|
||||||
return (
|
return (
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== "undefined" &&
|
||||||
'Notification' in window &&
|
"Notification" in window &&
|
||||||
'serviceWorker' in navigator &&
|
"serviceWorker" in navigator &&
|
||||||
'PushManager' in window
|
"PushManager" in window
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBrowserPushSubscription() {
|
export async function getBrowserPushSubscriptionState(
|
||||||
|
publicKey?: string,
|
||||||
|
): Promise<BrowserPushSubscriptionState> {
|
||||||
if (!supportsBrowserPush()) {
|
if (!supportsBrowserPush()) {
|
||||||
return null;
|
return {
|
||||||
|
subscription: null,
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await getRegistration();
|
const registration = await getRegistration();
|
||||||
return registration.pushManager.getSubscription();
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
const normalizedPublicKey = publicKey?.trim() || "";
|
||||||
|
const publicKeyBytes = normalizedPublicKey
|
||||||
|
? urlBase64ToUint8Array(normalizedPublicKey)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return {
|
||||||
|
subscription: null,
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscriptionMatchesPublicKey(subscription, publicKeyBytes)) {
|
||||||
|
return {
|
||||||
|
subscription: null,
|
||||||
|
stale: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscription,
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBrowserPushSubscription(publicKey?: string) {
|
||||||
|
const state = await getBrowserPushSubscriptionState(publicKey);
|
||||||
|
return state.subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureBrowserPushSubscription(
|
export async function ensureBrowserPushSubscription(
|
||||||
@@ -86,25 +168,34 @@ export async function ensureBrowserPushSubscription(
|
|||||||
ensureBrowserSupport();
|
ensureBrowserSupport();
|
||||||
|
|
||||||
if (!publicKey.trim()) {
|
if (!publicKey.trim()) {
|
||||||
throw new Error('Web Push 公钥未配置。');
|
throw new Error("Web Push 公钥未配置。");
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission =
|
const permission =
|
||||||
Notification.permission === 'granted'
|
Notification.permission === "granted"
|
||||||
? 'granted'
|
? "granted"
|
||||||
: await Notification.requestPermission();
|
: await Notification.requestPermission();
|
||||||
|
|
||||||
if (permission !== 'granted') {
|
if (permission !== "granted") {
|
||||||
throw new Error('浏览器通知权限未开启。');
|
throw new Error("浏览器通知权限未开启。");
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await getRegistration();
|
const registration = await getRegistration();
|
||||||
|
const publicKeyBytes = urlBase64ToUint8Array(publicKey);
|
||||||
let subscription = await registration.pushManager.getSubscription();
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (
|
||||||
|
subscription &&
|
||||||
|
!subscriptionMatchesPublicKey(subscription, publicKeyBytes)
|
||||||
|
) {
|
||||||
|
await subscription.unsubscribe().catch(() => undefined);
|
||||||
|
subscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
subscription = await registration.pushManager.subscribe({
|
subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
applicationServerKey: publicKeyBytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
playwright-smoke/playwright.web-push.config.ts
Normal file
68
playwright-smoke/playwright.web-push.config.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const backendBaseUrl = "http://127.0.0.1:5150";
|
||||||
|
const frontendBaseUrl = "http://127.0.0.1:4321";
|
||||||
|
const isCi = Boolean(process.env.CI);
|
||||||
|
const webPushChannel =
|
||||||
|
process.env.PLAYWRIGHT_WEB_PUSH_CHANNEL ??
|
||||||
|
(process.platform === "win32" ? "msedge" : undefined);
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
testMatch: /web-push\.real\.spec\.ts/,
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 180_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 20_000,
|
||||||
|
},
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
channel: webPushChannel,
|
||||||
|
baseURL: frontendBaseUrl,
|
||||||
|
headless: true,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: "cargo loco start --server-and-worker",
|
||||||
|
cwd: path.resolve(repoRoot, "backend"),
|
||||||
|
url: `${backendBaseUrl}/api/site_settings`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL:
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
|
||||||
|
REDIS_URL: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
|
||||||
|
TERMI_ADMIN_LOCAL_LOGIN_ENABLED:
|
||||||
|
process.env.TERMI_ADMIN_LOCAL_LOGIN_ENABLED ?? "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "pnpm dev --host 127.0.0.1 --port 4321",
|
||||||
|
cwd: path.resolve(repoRoot, "frontend"),
|
||||||
|
url: frontendBaseUrl,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PUBLIC_API_BASE_URL: `${backendBaseUrl}/api`,
|
||||||
|
INTERNAL_API_BASE_URL: `${backendBaseUrl}/api`,
|
||||||
|
PUBLIC_IMAGE_ALLOWED_HOSTS: "127.0.0.1,127.0.0.1:5150",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
122
playwright-smoke/tests/web-push.real.spec.ts
Normal file
122
playwright-smoke/tests/web-push.real.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { chromium, expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const BACKEND_BASE_URL = "http://127.0.0.1:5150";
|
||||||
|
const FRONTEND_BASE_URL = "http://127.0.0.1:4321";
|
||||||
|
|
||||||
|
type AdminSubscriptionRecord = {
|
||||||
|
id: number;
|
||||||
|
channel_type: string;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminSubscriptionListResponse = {
|
||||||
|
subscriptions: AdminSubscriptionRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__termiPushMessages?: unknown[];
|
||||||
|
__termiSubscriptionPopupReady?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("浏览器订阅后可以收到测试推送", async ({ request }, testInfo) => {
|
||||||
|
const context = await chromium.launchPersistentContext(
|
||||||
|
testInfo.outputPath("web-push-user-data"),
|
||||||
|
{
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.grantPermissions(["notifications"], {
|
||||||
|
origin: FRONTEND_BASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = context.pages()[0] ?? (await context.newPage());
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__termiPushMessages = [];
|
||||||
|
navigator.serviceWorker?.addEventListener("message", (event) => {
|
||||||
|
if (event.data?.type === "termi:web-push-received") {
|
||||||
|
window.__termiPushMessages?.push(event.data.payload ?? null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_BASE_URL}/maintenance?returnTo=%2F`);
|
||||||
|
await page.getByLabel("访问口令").fill("termi");
|
||||||
|
await page.getByRole("button", { name: "进入站点" }).click();
|
||||||
|
await page.waitForURL(`${FRONTEND_BASE_URL}/`);
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__termiSubscriptionPopupReady === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator("[data-subscription-popup-open]").click();
|
||||||
|
|
||||||
|
const subscribeResponsePromise = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes("/api/subscriptions/combined") &&
|
||||||
|
response.request().method() === "POST",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator("[data-subscription-popup-submit]").click();
|
||||||
|
|
||||||
|
const subscribeResponse = await subscribeResponsePromise;
|
||||||
|
expect(subscribeResponse.ok()).toBeTruthy();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-subscription-popup-status][data-state="success"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const endpoint = await page.evaluate(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
return subscription?.endpoint ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(endpoint).toBeTruthy();
|
||||||
|
|
||||||
|
const loginResponse = await request.post(
|
||||||
|
`${BACKEND_BASE_URL}/api/admin/session/login`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
username: "admin",
|
||||||
|
password: "admin123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(loginResponse.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const subscriptionsResponse = await request.get(
|
||||||
|
`${BACKEND_BASE_URL}/api/admin/subscriptions`,
|
||||||
|
);
|
||||||
|
expect(subscriptionsResponse.ok()).toBeTruthy();
|
||||||
|
const subscriptionsPayload =
|
||||||
|
(await subscriptionsResponse.json()) as AdminSubscriptionListResponse;
|
||||||
|
const subscription = subscriptionsPayload.subscriptions.find(
|
||||||
|
(item) => item.channel_type === "web_push" && item.target === endpoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(subscription).toBeTruthy();
|
||||||
|
|
||||||
|
const testResponse = await request.post(
|
||||||
|
`${BACKEND_BASE_URL}/api/admin/subscriptions/${subscription?.id}/test`,
|
||||||
|
);
|
||||||
|
expect(testResponse.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Array.isArray(window.__termiPushMessages) &&
|
||||||
|
window.__termiPushMessages.length > 0,
|
||||||
|
undefined,
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages = await page.evaluate(() => window.__termiPushMessages ?? []);
|
||||||
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user