From 381dc9b85443c5ea63a1a6a094fa482d58187bb3 Mon Sep 17 00:00:00 2001 From: limitcool Date: Sat, 4 Apr 2026 04:15:20 +0800 Subject: [PATCH] Fix web push delivery handling and worker console --- .gitea/workflows/ui-regression.yml | 81 +- README.md | 4 +- admin/src/lib/worker-progress.ts | 11 + admin/src/pages/dashboard-page.tsx | 393 +++--- admin/src/pages/site-settings-page.tsx | 22 +- admin/src/pages/workers-page.tsx | 1129 ++++++++++------- backend/README.md | 4 +- backend/src/services/ai.rs | 15 + backend/src/services/subscriptions.rs | 182 ++- backend/src/services/web_push.rs | 41 +- backend/src/services/worker_jobs.rs | 21 + backend/src/workers/ai_reindex.rs | 10 + backend/src/workers/notification_delivery.rs | 4 - dev.ps1 | 4 +- frontend/public/termi-web-push-sw.js | 74 +- .../src/components/SubscriptionPopup.astro | 24 +- frontend/src/lib/utils/web-push.ts | 145 ++- .../playwright.web-push.config.ts | 68 + playwright-smoke/tests/web-push.real.spec.ts | 122 ++ 19 files changed, 1607 insertions(+), 747 deletions(-) create mode 100644 playwright-smoke/playwright.web-push.config.ts create mode 100644 playwright-smoke/tests/web-push.real.spec.ts diff --git a/.gitea/workflows/ui-regression.yml b/.gitea/workflows/ui-regression.yml index fb1917a..052b3a2 100644 --- a/.gitea/workflows/ui-regression.yml +++ b/.gitea/workflows/ui-regression.yml @@ -1,21 +1,6 @@ name: ui-regression 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: jobs: @@ -35,6 +20,11 @@ jobs: uses: actions/setup-node@v4 with: 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 working-directory: frontend @@ -48,7 +38,15 @@ jobs: working-directory: playwright-smoke 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 + if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: playwright-smoke run: pnpm exec playwright install --with-deps chromium @@ -69,71 +67,22 @@ jobs: VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321 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 id: ui_frontend working-directory: playwright-smoke continue-on-error: true env: - PLAYWRIGHT_USE_BUILT_APP: '1' + PLAYWRIGHT_USE_BUILT_APP: "1" 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 id: ui_admin working-directory: playwright-smoke continue-on-error: true env: - PLAYWRIGHT_USE_BUILT_APP: '1' + PLAYWRIGHT_USE_BUILT_APP: "1" 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 if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success' run: exit 1 diff --git a/README.md b/README.md index 11eda4d..76217c0 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,11 @@ pnpm dev ```powershell cd backend $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 镜像) 补充部署分层与反代说明见: diff --git a/admin/src/lib/worker-progress.ts b/admin/src/lib/worker-progress.ts index f4c8567..7e9161a 100644 --- a/admin/src/lib/worker-progress.ts +++ b/admin/src/lib/worker-progress.ts @@ -114,3 +114,14 @@ export function formatWorkerProgress( return progress.message ?? (details || null); } + +export function getWorkerProgressPercent( + job: Pick, +): number | null { + const progress = getWorkerProgress(job); + if (typeof progress?.percent !== "number") { + return null; + } + + return Math.max(0, Math.min(100, Math.round(progress.percent))); +} diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx index fd13070..cbdd843 100644 --- a/admin/src/pages/dashboard-page.tsx +++ b/admin/src/pages/dashboard-page.tsx @@ -10,15 +10,22 @@ import { Star, Tags, Workflow, -} from 'lucide-react' -import { startTransition, useCallback, useEffect, useState } from 'react' -import { Link } from 'react-router-dom' -import { toast } from 'sonner' +} from "lucide-react"; +import { startTransition, useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatDateTime } from "@/lib/admin-format"; import { Table, TableBody, @@ -26,9 +33,9 @@ import { TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { adminApi, ApiError } from '@/lib/api' -import { buildFrontendUrl } from '@/lib/frontend-url' +} from "@/components/ui/table"; +import { adminApi, ApiError } from "@/lib/api"; +import { buildFrontendUrl } from "@/lib/frontend-url"; import { formatCommentScope, formatPostStatus, @@ -37,8 +44,12 @@ import { formatPostVisibility, formatReviewStatus, formatReviewType, -} from '@/lib/admin-format' -import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types' +} from "@/lib/admin-format"; +import type { + AdminAnalyticsResponse, + AdminDashboardResponse, + WorkerOverview, +} from "@/lib/types"; function StatCard({ label, @@ -46,17 +57,21 @@ function StatCard({ note, icon: Icon, }: { - label: string - value: number - note: string - icon: typeof Rss + label: string; + value: number; + note: string; + icon: typeof Rss; }) { return (
-

{label}

-
{value}
+

+ {label} +

+
+ {value} +

{note}

@@ -64,75 +79,81 @@ function StatCard({
- ) + ); } function formatAiSourceLabel(value: string) { switch (value) { - case 'chatgpt-search': - return 'ChatGPT Search' - case 'perplexity': - return 'Perplexity' - case 'copilot-bing': - return 'Copilot / Bing' - case 'gemini': - return 'Gemini' - case 'claude': - return 'Claude' - case 'google': - return 'Google' - case 'duckduckgo': - return 'DuckDuckGo' - case 'kagi': - return 'Kagi' - case 'direct': - return 'Direct' + case "chatgpt-search": + return "ChatGPT Search"; + case "perplexity": + return "Perplexity"; + case "copilot-bing": + return "Copilot / Bing"; + case "gemini": + return "Gemini"; + case "claude": + return "Claude"; + case "google": + return "Google"; + case "duckduckgo": + return "DuckDuckGo"; + case "kagi": + return "Kagi"; + case "direct": + return "Direct"; default: - return value + return value; } } export function DashboardPage() { - const [data, setData] = useState(null) - const [workerOverview, setWorkerOverview] = useState(null) - const [analytics, setAnalytics] = useState(null) - const [loading, setLoading] = useState(true) - const [refreshing, setRefreshing] = useState(false) + const [data, setData] = useState(null); + const [workerOverview, setWorkerOverview] = useState( + null, + ); + const [analytics, setAnalytics] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const loadDashboard = useCallback(async (showToast = false) => { try { if (showToast) { - setRefreshing(true) + setRefreshing(true); } const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([ adminApi.dashboard(), adminApi.getWorkersOverview(), adminApi.analytics(), - ]) + ]); startTransition(() => { - setData(next) - setWorkerOverview(nextWorkerOverview) - setAnalytics(nextAnalytics) - }) + setData(next); + setWorkerOverview(nextWorkerOverview); + setAnalytics(nextAnalytics); + }); if (showToast) { - toast.success('仪表盘已刷新。') + toast.success("仪表盘已刷新。"); } } catch (error) { if (error instanceof ApiError && error.status === 401) { - return + return; } - toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。') + toast.error( + error instanceof ApiError ? error.message : "无法加载仪表盘。", + ); } finally { - setLoading(false) - setRefreshing(false) + setLoading(false); + setRefreshing(false); } - }, []) + }, []); useEffect(() => { - void loadDashboard(false) - }, [loadDashboard]) + void loadDashboard(false); + }, [loadDashboard]); if (loading || !data || !workerOverview || !analytics) { return ( @@ -147,24 +168,24 @@ export function DashboardPage() { - ) + ); } const statCards = [ { - label: '文章总数', + label: "文章总数", value: data.stats.total_posts, note: `内容库中共有 ${data.stats.total_comments} 条评论`, icon: Rss, }, { - label: '待审核评论', + label: "待审核评论", value: data.stats.pending_comments, - note: '等待审核处理', + note: "等待审核处理", icon: MessageSquareWarning, }, { - label: '发布待办', + label: "发布待办", value: data.stats.draft_posts + data.stats.scheduled_posts + @@ -174,30 +195,32 @@ export function DashboardPage() { icon: Clock3, }, { - label: '分类数量', + label: "分类数量", value: data.stats.total_categories, note: `当前共有 ${data.stats.total_tags} 个标签`, icon: FolderTree, }, { - label: 'AI 分块', + label: "AI 分块", value: data.stats.ai_chunks, - note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭', + note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭", icon: BrainCircuit, }, { - label: 'Worker 活动', + label: "Worker 活动", value: workerOverview.active_jobs, note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`, icon: Workflow, }, - ] + ]; const aiTrafficShare = analytics.content_overview.page_views_last_7d > 0 - ? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100 - : 0 - const topAiSource = analytics.ai_referrers_last_7d[0] - const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length + ? (analytics.ai_discovery_page_views_last_7d / + analytics.content_overview.page_views_last_7d) * + 100 + : 0; + const topAiSource = analytics.ai_referrers_last_7d[0]; + const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length; return (
@@ -207,14 +230,15 @@ export function DashboardPage() {

运营总览

- 这里汇总了最重要的发布、审核和 AI 信号,让日常运营在一个独立后台里完成闭环。 + 这里汇总了最重要的发布、审核和 AI + 信号,让日常运营在一个独立后台里完成闭环。

@@ -241,9 +265,7 @@ export function DashboardPage() {
最近文章 - - 最近同步到前台的文章内容。 - + 最近同步到前台的文章内容。
{data.recent_posts.length} 条
@@ -265,9 +287,13 @@ export function DashboardPage() {
{post.title} - {post.pinned ? 置顶 : null} + {post.pinned ? ( + 置顶 + ) : null}
-

{post.slug}

+

+ {post.slug} +

@@ -275,12 +301,18 @@ export function DashboardPage() {
- {formatPostStatus(post.status)} - {formatPostVisibility(post.visibility)} + + {formatPostStatus(post.status)} + + + {formatPostVisibility(post.visibility)} +
{post.category} - {post.created_at} + + {post.created_at} + ))} @@ -291,19 +323,19 @@ export function DashboardPage() { 站点状态 - - 快速查看前台站点与 AI 索引状态。 - + 快速查看前台站点与 AI 索引状态。

{data.site.site_name}

-

{data.site.site_url}

+

+ {data.site.site_url} +

- - {data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'} + + {data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
@@ -314,7 +346,9 @@ export function DashboardPage() { 评测

- {data.stats.total_reviews} + + {data.stats.total_reviews} +
@@ -323,7 +357,9 @@ export function DashboardPage() { 友链

- {data.stats.total_links} + + {data.stats.total_links} +
@@ -335,25 +371,35 @@ export function DashboardPage() {

-

{data.stats.draft_posts}

+

+ {data.stats.draft_posts} +

草稿

-

{data.stats.scheduled_posts}

+

+ {data.stats.scheduled_posts} +

定时发布

-

{data.stats.offline_posts}

+

+ {data.stats.offline_posts} +

手动下线

-

{data.stats.expired_posts}

+

+ {data.stats.expired_posts} +

自动过期

私有 {data.stats.private_posts} - 不公开 {data.stats.unlisted_posts} + + 不公开 {data.stats.unlisted_posts} +
@@ -362,7 +408,9 @@ export function DashboardPage() { 最近一次 AI 索引

- {data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'} + {data.site.ai_last_indexed_at + ? formatDateTime(data.site.ai_last_indexed_at) + : "站点还没有建立过索引。"}

@@ -372,9 +420,13 @@ export function DashboardPage() {

GEO / AI 来源概览

-

{analytics.ai_discovery_page_views_last_7d}

+

+ {analytics.ai_discovery_page_views_last_7d} +

- 最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。 + 最近 7 天来自 ChatGPT + Search、Perplexity、Copilot/Bing、Gemini、Claude + 的页面访问。

@@ -384,23 +436,41 @@ export function DashboardPage() {
-
访问占比
-
{Math.round(aiTrafficShare)}%
-
基于近 7 天全部 page_view
-
-
-
最高来源
-
- {topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'} +
+ 访问占比 +
+
+ {Math.round(aiTrafficShare)}%
- {topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'} + 基于近 7 天全部 page_view
-
已识别来源
-
{totalAiSourceBuckets}
-
当前已聚合的 AI 搜索渠道
+
+ 最高来源 +
+
+ {topAiSource + ? formatAiSourceLabel(topAiSource.referrer) + : "暂无"} +
+
+ {topAiSource + ? `${topAiSource.count} 次访问` + : "等待来源数据"} +
+
+
+
+ 已识别来源 +
+
+ {totalAiSourceBuckets} +
+
+ 当前已聚合的 AI 搜索渠道 +
@@ -408,15 +478,24 @@ export function DashboardPage() {
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => { 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, - )}%` + )}%`; return (
- {formatAiSourceLabel(item.referrer)} - {item.count} + + {formatAiSourceLabel(item.referrer)} + + + {item.count} +
- ) + ); })}
) : ( -

最近 7 天还没有识别到 AI 搜索来源流量。

+

+ 最近 7 天还没有识别到 AI 搜索来源流量。 +

)}
@@ -440,12 +521,17 @@ export function DashboardPage() { Worker 健康

- 当前排队 {workerOverview.queued}、运行 {workerOverview.running}、失败 {workerOverview.failed}。 + 当前排队 {workerOverview.queued}、运行{" "} + {workerOverview.running}、失败 {workerOverview.failed}。

@@ -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" >
-
{item.label}
-
{item.worker_name}
+
+ {item.label} +
+
+ {item.worker_name} +
-
Q {item.queued} · R {item.running}
+
+ Q {item.queued} · R {item.running} +
ERR {item.failed}
@@ -510,11 +614,11 @@ export function DashboardPage() {
待审核评论 - - 在当前管理端直接查看审核队列。 - + 在当前管理端直接查看审核队列。
- {data.pending_comments.length} 条待处理 + + {data.pending_comments.length} 条待处理 +
@@ -543,7 +647,9 @@ export function DashboardPage() { {comment.post_slug} - {comment.created_at} + + {comment.created_at} + ))} @@ -556,11 +662,11 @@ export function DashboardPage() {
待审核友链 - - 等待审核和互链确认的申请。 - + 等待审核和互链确认的申请。
- {data.pending_friend_links.length} 条待处理 + + {data.pending_friend_links.length} 条待处理 +
{data.pending_friend_links.map((link) => ( @@ -591,9 +697,7 @@ export function DashboardPage() { 最近评测 - - 最近同步到前台评测页的内容。 - + 最近同步到前台评测页的内容。 {data.recent_reviews.map((review) => ( @@ -604,11 +708,14 @@ export function DashboardPage() {

{review.title}

- {formatReviewType(review.review_type)} · {formatReviewStatus(review.status)} + {formatReviewType(review.review_type)} ·{" "} + {formatReviewStatus(review.status)}

-
{review.rating}/5
+
+ {review.rating}/5 +

{review.review_date}

@@ -620,5 +727,5 @@ export function DashboardPage() {
- ) + ); } diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx index 0e35f9c..693c790 100644 --- a/admin/src/pages/site-settings-page.tsx +++ b/admin/src/pages/site-settings-page.tsx @@ -26,7 +26,11 @@ 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 { formatWorkerProgress } from "@/lib/worker-progress"; +import { formatDateTime } from "@/lib/admin-format"; +import { + formatWorkerProgress, + getWorkerProgressPercent, +} from "@/lib/worker-progress"; import type { AdminSiteSettingsResponse, AiProviderConfig, @@ -2119,7 +2123,9 @@ export function SiteSettingsPage() { 最近索引时间

- {form.ai_last_indexed_at ?? "索引尚未建立。"} + {form.ai_last_indexed_at + ? formatDateTime(form.ai_last_indexed_at) + : "索引尚未建立。"}

{reindexJobId ? (

@@ -2133,6 +2139,18 @@ export function SiteSettingsPage() { "任务已经开始,正在等待下一次进度更新。"}

) : null} + {reindexJobResult && + getWorkerProgressPercent({ result: reindexJobResult }) !== + null ? ( +
+
+
+ ) : null}
diff --git a/admin/src/pages/workers-page.tsx b/admin/src/pages/workers-page.tsx index d6f0032..ed34bbc 100644 --- a/admin/src/pages/workers-page.tsx +++ b/admin/src/pages/workers-page.tsx @@ -1,5 +1,10 @@ import { - LoaderCircle, + Activity, + AlertTriangle, + CheckCircle2, + Clock3, + Filter, + PlayCircle, RefreshCcw, RotateCcw, Send, @@ -15,6 +20,7 @@ import { useMemo, useState, } from "react"; +import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -29,19 +35,15 @@ import { 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 { formatWorkerProgress } from "@/lib/worker-progress"; -import type { WorkerJobRecord, WorkerOverview } from "@/lib/types"; +import { formatDateTime } from "@/lib/admin-format"; +import { + formatWorkerProgress, + getWorkerProgressPercent, +} from "@/lib/worker-progress"; +import type { WorkerJobRecord, WorkerOverview, WorkerStats } from "@/lib/types"; import { cn } from "@/lib/utils"; -import { useSearchParams } from "react-router-dom"; function prettyJson(value: unknown) { if (value === null || value === undefined) { @@ -72,6 +74,100 @@ function statusVariant(status: string) { } } +function summaryIconTone(status: string) { + switch (status) { + case "running": + return "border-blue-500/20 bg-blue-500/10 text-blue-600"; + case "succeeded": + return "border-emerald-500/20 bg-emerald-500/10 text-emerald-600"; + case "failed": + return "border-rose-500/20 bg-rose-500/10 text-rose-600"; + case "queued": + return "border-sky-500/20 bg-sky-500/10 text-sky-600"; + default: + return "border-primary/20 bg-primary/10 text-primary"; + } +} + +function workerStatTotal(item: WorkerStats) { + return ( + item.queued + item.running + item.succeeded + item.failed + item.cancelled + ); +} + +function workerHealthLabel(item: WorkerStats) { + if (item.failed > 0) { + return "有失败"; + } + if (item.running > 0) { + return "执行中"; + } + if (item.queued > 0) { + return "排队中"; + } + return "稳定"; +} + +function workerHealthTone(item: WorkerStats) { + if (item.failed > 0) { + return "text-rose-600"; + } + if (item.running > 0) { + return "text-blue-600"; + } + if (item.queued > 0) { + return "text-sky-600"; + } + return "text-emerald-600"; +} + +function relatedEntityText(job: WorkerJobRecord) { + if (!job.related_entity_type || !job.related_entity_id) { + return "—"; + } + + return `${job.related_entity_type}:${job.related_entity_id}`; +} + +function jobTitle(job: WorkerJobRecord) { + return job.display_name ?? job.worker_name; +} + +function SummaryCard({ + label, + value, + hint, + icon: Icon, + tone, +}: { + label: string; + value: string | number; + hint: string; + icon: typeof SquareTerminal; + tone: string; +}) { + return ( + + +
+
+ {label} +
+
+ {value} +
+
+ {hint} +
+
+
+ +
+
+
+ ); +} + const EMPTY_OVERVIEW: WorkerOverview = { total_jobs: 0, queued: 0, @@ -150,7 +246,7 @@ export function WorkersPage() { setTotal(nextJobs.total); }); if (showToast) { - toast.success("Worker 管理面板已刷新。"); + toast.success("Worker 面板已刷新。"); } } catch (error) { toast.error( @@ -193,6 +289,23 @@ export function WorkersPage() { [jobs, selectedJobId], ); + const selectedWorkerLabel = useMemo(() => { + if (workerFilter === "all") { + return null; + } + return ( + overview.catalog.find((item) => item.worker_name === workerFilter) + ?.label ?? workerFilter + ); + }, [overview.catalog, workerFilter]); + + const selectedProgressText = selectedJob + ? formatWorkerProgress(selectedJob) + : null; + const selectedProgressPercent = selectedJob + ? getWorkerProgressPercent(selectedJob) + : null; + const runTask = useCallback( async (task: "weekly" | "monthly" | "retry") => { try { @@ -225,8 +338,8 @@ export function WorkersPage() { if (loading) { return (
- - + +
); } @@ -235,14 +348,20 @@ export function WorkersPage() {
- Workers / Queue +
+ Workers / Queue + 每 5 秒自动刷新 + {selectedWorkerLabel ? ( + 当前聚焦 {selectedWorkerLabel} + ) : null} +
+

- 异步 Worker 控制台 + 异步任务控制台

- 统一查看后台下载、通知投递与 digest / - 重试任务;支持筛选、查看详情、取消、重跑与手动触发。 + 统一查看队列堆积、执行进度和失败任务。把手动调度、失败排查和运行状态都收在同一页里。

@@ -253,173 +372,247 @@ export function WorkersPage() { onClick={() => void loadData(true)} disabled={refreshing} > - + {refreshing ? "刷新中..." : "刷新"} - - -
-
- {[ - { - label: "总任务", - value: overview.total_jobs, - hint: `${overview.worker_stats.length} 种 worker`, - icon: SquareTerminal, - }, - { - label: "排队中", - value: overview.queued, - hint: "queued", - icon: LoaderCircle, - }, - { - label: "运行中", - value: overview.running, - hint: "running", - icon: Workflow, - }, - { - label: "成功", - value: overview.succeeded, - hint: "succeeded", - icon: Send, - }, - { - label: "失败", - value: overview.failed, - hint: "failed", - icon: RotateCcw, - }, - { - label: "已取消", - value: overview.cancelled, - hint: "cancelled", - icon: StopCircle, - }, - ].map((item) => { - const Icon = item.icon; - return ( - - -
-
- {item.label} -
-
- {item.value} -
-
- {item.hint} -
-
-
- -
-
-
- ); - })} +
+ + + 0 ? "failed" : "succeeded")} + /> + item.can_retry || item.can_cancel).length}`} + hint="可取消或重跑" + icon={PlayCircle} + tone={summaryIconTone("queued")} + /> + + +
+
+ 快捷操作 +
+
+ 保留手动调度入口,但不再做成一整块独立视觉。 +
+
+
+ + + +
+
+
- - - Worker 分类视图 - - 快速看每类 worker / task 当前堆积、失败与最近执行情况。 - + + +
+
+ Worker 视图 + + 每张卡代表一个 worker,优先显示失败、排队和最近一次活动。 + +
+
+ + 点击任意卡片即可快速锁定该 worker。 +
+
- - {overview.worker_stats.map((item) => ( - - ))} + +
+ {[ + ["Q", item.queued], + ["R", item.running], + ["OK", item.succeeded], + ["ERR", item.failed], + ["X", item.cancelled], + ].map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ +
+ 最近活动 + + {item.last_job_at + ? formatDateTime(item.last_job_at) + : "—"} + +
+ + ); + })} +
+ ) : ( +
+ 还没有可展示的 worker 统计。 +
+ )}
-
- - - 任务历史 - - 当前筛选后共 {total} 条,列表保留最近 120 条任务记录。 - - - +
+ + +
+
+ 任务流 + + 当前筛选后共 {total} 条,列表保留最近 120 条记录。 + +
+
+ {statusFilter !== "all" ? ( + + {statusFilter} + + ) : null} + {kindFilter !== "all" ? ( + {kindFilter} + ) : null} + {selectedWorkerLabel ? ( + {selectedWorkerLabel} + ) : null} +
+
+
- - - ID - 任务 - 状态 - 来源 - 实体 - 时间 - - - - {jobs.map((item) => ( - setSelectedJobId(item.id)} - > - - #{item.id} - - -
-
- {item.display_name ?? item.worker_name} -
-
- {item.worker_name} -
-
-
- -
- - {item.status} - - {formatWorkerProgress(item) ? ( -
- {formatWorkerProgress(item)} -
- ) : null} - {item.cancel_requested ? ( -
- cancel requested -
- ) : null} -
-
- -
-
{item.job_kind}
-
{item.requested_by ?? "system"}
-
-
- - {item.related_entity_type && item.related_entity_id - ? `${item.related_entity_type}:${item.related_entity_id}` - : "—"} - - -
{item.queued_at ?? item.created_at}
-
done: {item.finished_at ?? "—"}
-
-
- ))} - {!jobs.length ? ( - - - 当前筛选没有匹配任务。 - - - ) : null} -
-
-
-
- - - - 任务详情 - - 查看 payload / result / error,并对单个任务执行取消或重跑。 - + - {selectedJob ? ( -
-
- #{selectedJob.id} - - {selectedJob.status} - - {selectedJob.job_kind} -
- -
-

- {selectedJob.display_name ?? selectedJob.worker_name} -

-

- {selectedJob.worker_name} -

-
- -
- {[ - ["请求人", selectedJob.requested_by ?? "system"], - ["来源", selectedJob.requested_source ?? "system"], - [ - "关联实体", - selectedJob.related_entity_type && - selectedJob.related_entity_id - ? `${selectedJob.related_entity_type}:${selectedJob.related_entity_id}` - : "—", - ], - [ - "尝试次数", - `${selectedJob.attempts_count} / ${selectedJob.max_attempts}`, - ], - [ - "排队时间", - selectedJob.queued_at ?? selectedJob.created_at, - ], - ["开始时间", selectedJob.started_at ?? "—"], - ["完成时间", selectedJob.finished_at ?? "—"], - [ - "上游任务", - selectedJob.parent_job_id - ? `#${selectedJob.parent_job_id}` - : "—", - ], - ].map(([label, value]) => ( -
+ {jobs.map((item) => { + const progressText = formatWorkerProgress(item); + const progressPercent = getWorkerProgressPercent(item); + const isSelected = selectedJobId === item.id; + return ( + - -
- -
-
-
- Payload -
-
-                      {prettyJson(selectedJob.payload)}
-                    
-
- -
-
- Result -
-
-                      {prettyJson(selectedJob.result)}
-                    
-
- -
-
- Error -
-
-                      {selectedJob.error_text ?? "—"}
-                    
-
-
+ {progressText ? ( +
+
+ 进度 + {progressPercent !== null ? ( + {progressPercent}% + ) : null} +
+ {progressPercent !== null ? ( +
+
+
+ ) : null} +
+ {progressText} +
+
+ ) : null} + + ); + })}
) : ( -
- 暂无可查看的任务详情。 +
+ 当前筛选没有匹配任务。
)} + +
+ + +
+
+ 任务详情 + + 选中左侧任务后,这里展示上下文、进度和原始数据。 + +
+ +
+
+ + + {selectedJob ? ( +
+
+
+ #{selectedJob.id} + + {selectedJob.status} + + {selectedJob.job_kind} +
+ +
+
+ {jobTitle(selectedJob)} +
+
+ {selectedJob.worker_name} +
+
+ + {selectedProgressText ? ( +
+
+ 执行进度 + {selectedProgressPercent !== null ? ( + {selectedProgressPercent}% + ) : null} +
+ {selectedProgressPercent !== null ? ( +
+
+
+ ) : null} +
+ {selectedProgressText} +
+
+ ) : null} +
+ +
+ {[ + ["请求人", selectedJob.requested_by ?? "system"], + ["来源", selectedJob.requested_source ?? "system"], + ["关联实体", relatedEntityText(selectedJob)], + [ + "尝试次数", + `${selectedJob.attempts_count} / ${selectedJob.max_attempts}`, + ], + [ + "排队时间", + formatDateTime( + selectedJob.queued_at ?? selectedJob.created_at, + ), + ], + [ + "开始时间", + selectedJob.started_at + ? formatDateTime(selectedJob.started_at) + : "—", + ], + [ + "完成时间", + selectedJob.finished_at + ? formatDateTime(selectedJob.finished_at) + : "—", + ], + [ + "上游任务", + selectedJob.parent_job_id + ? `#${selectedJob.parent_job_id}` + : "—", + ], + ].map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ +
+ + +
+ +
+
+
+ + Payload +
+