From a516be2e91bde722f10314f7f8b014a304e7dc5f Mon Sep 17 00:00:00 2001 From: limitcool Date: Thu, 2 Apr 2026 03:43:37 +0800 Subject: [PATCH] feat: add worker operations and fix gitea actions --- .gitea/workflows/ui-regression.yml | 76 +- admin/src/App.tsx | 12 + admin/src/components/app-shell.tsx | 7 + admin/src/lib/api.ts | 57 +- admin/src/lib/types.ts | 89 ++ admin/src/pages/dashboard-page.tsx | 88 +- admin/src/pages/media-page.tsx | 166 ++++ admin/src/pages/subscriptions-page.tsx | 71 +- admin/src/pages/workers-page.tsx | 529 +++++++++++ backend/Dockerfile | 2 - backend/migration/src/lib.rs | 2 + .../m20260402_000036_create_worker_jobs.rs | 98 ++ backend/src/controllers/admin_api.rs | 79 +- backend/src/controllers/admin_ops.rs | 199 ++++- backend/src/controllers/review.rs | 26 +- backend/src/models/_entities/mod.rs | 1 + backend/src/models/_entities/prelude.rs | 1 + backend/src/models/_entities/worker_jobs.rs | 43 + backend/src/services/mod.rs | 1 + backend/src/services/subscriptions.rs | 28 +- backend/src/services/worker_jobs.rs | 835 ++++++++++++++++++ backend/src/workers/downloader.rs | 250 +++++- backend/src/workers/notification_delivery.rs | 28 +- frontend/src/components/PostCard.astro | 18 +- .../src/components/SubscriptionPopup.astro | 10 + frontend/src/lib/utils/index.ts | 45 + frontend/src/pages/articles/[slug].astro | 12 +- frontend/src/pages/categories/[slug].astro | 173 ++++ frontend/src/pages/categories/index.astro | 311 +------ frontend/src/pages/index.astro | 8 + frontend/src/pages/reviews/[id].astro | 112 ++- frontend/src/pages/reviews/index.astro | 353 ++++---- frontend/src/pages/tags/[slug].astro | 176 ++++ frontend/src/pages/tags/index.astro | 295 +------ playwright-smoke/mock-server.mjs | 452 +++++++++- playwright-smoke/tests/admin.spec.ts | 73 +- playwright-smoke/tests/frontend.spec.ts | 43 +- 37 files changed, 3890 insertions(+), 879 deletions(-) create mode 100644 admin/src/pages/workers-page.tsx create mode 100644 backend/migration/src/m20260402_000036_create_worker_jobs.rs create mode 100644 backend/src/models/_entities/worker_jobs.rs create mode 100644 backend/src/services/worker_jobs.rs create mode 100644 frontend/src/pages/categories/[slug].astro create mode 100644 frontend/src/pages/tags/[slug].astro diff --git a/.gitea/workflows/ui-regression.yml b/.gitea/workflows/ui-regression.yml index e09241e..6fa4d2d 100644 --- a/.gitea/workflows/ui-regression.yml +++ b/.gitea/workflows/ui-regression.yml @@ -100,67 +100,27 @@ jobs: cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results fi - - name: Upload frontend HTML report + - name: Summarize Playwright artifact paths if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-html-report-frontend - path: playwright-smoke/.artifacts/frontend/playwright-report - retention-days: 14 - if-no-files-found: ignore + shell: bash + run: | + set -euo pipefail - - name: Upload admin HTML report - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-html-report-admin - path: playwright-smoke/.artifacts/admin/playwright-report - retention-days: 14 - if-no-files-found: ignore + echo "Gitea Actions 当前不支持 actions/upload-artifact@v4,改为直接输出产物目录:" - - name: Upload frontend raw results - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-raw-results-frontend - path: playwright-smoke/.artifacts/frontend/test-results - retention-days: 14 - if-no-files-found: ignore - - - name: Upload admin raw results - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-raw-results-admin - path: playwright-smoke/.artifacts/admin/test-results - retention-days: 14 - if-no-files-found: ignore - - - name: Upload frontend failure screenshots / videos / traces - if: steps.ui_frontend.outcome != 'success' - uses: actions/upload-artifact@v4 - with: - name: playwright-failure-artifacts-frontend - path: | - playwright-smoke/.artifacts/frontend/test-results/**/*.png - playwright-smoke/.artifacts/frontend/test-results/**/*.webm - playwright-smoke/.artifacts/frontend/test-results/**/*.zip - playwright-smoke/.artifacts/frontend/test-results/**/error-context.md - retention-days: 21 - if-no-files-found: ignore - - - name: Upload admin failure screenshots / videos / traces - if: steps.ui_admin.outcome != 'success' - uses: actions/upload-artifact@v4 - with: - name: playwright-failure-artifacts-admin - path: | - playwright-smoke/.artifacts/admin/test-results/**/*.png - playwright-smoke/.artifacts/admin/test-results/**/*.webm - playwright-smoke/.artifacts/admin/test-results/**/*.zip - playwright-smoke/.artifacts/admin/test-results/**/error-context.md - retention-days: 21 - if-no-files-found: ignore + 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' diff --git a/admin/src/App.tsx b/admin/src/App.tsx index aee3dbf..0b2b026 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -94,6 +94,10 @@ const SubscriptionsPage = lazy(async () => { const mod = await import('@/pages/subscriptions-page') return { default: mod.SubscriptionsPage } }) +const WorkersPage = lazy(async () => { + const mod = await import('@/pages/workers-page') + return { default: mod.WorkersPage } +}) type SessionContextValue = { session: AdminSessionResponse @@ -389,6 +393,14 @@ function AppRoutes() { } /> + + + + } + /> - request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, { + request<{ queued: boolean; id: number; delivery_id: number; job_id?: number | null }>(`/api/admin/subscriptions/${id}/test`, { method: 'POST', }), listSubscriptionDeliveries: async (limit = 80) => @@ -248,6 +254,42 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ period }), }), + getWorkersOverview: () => request('/api/admin/workers/overview'), + listWorkerJobs: (query?: { + status?: string + jobKind?: string + workerName?: string + search?: string + limit?: number + }) => + request( + appendQueryParams('/api/admin/workers/jobs', { + status: query?.status, + job_kind: query?.jobKind, + worker_name: query?.workerName, + search: query?.search, + limit: query?.limit, + }), + ), + getWorkerJob: (id: number) => request(`/api/admin/workers/jobs/${id}`), + cancelWorkerJob: (id: number) => + request(`/api/admin/workers/jobs/${id}/cancel`, { + method: 'POST', + }), + retryWorkerJob: (id: number) => + request(`/api/admin/workers/jobs/${id}/retry`, { + method: 'POST', + }), + runRetryDeliveriesWorker: (limit?: number) => + request('/api/admin/workers/tasks/retry-deliveries', { + method: 'POST', + body: JSON.stringify({ limit }), + }), + runDigestWorker: (period: 'weekly' | 'monthly') => + request('/api/admin/workers/tasks/digest', { + method: 'POST', + body: JSON.stringify({ period }), + }), dashboard: () => request('/api/admin/dashboard'), analytics: () => request('/api/admin/analytics'), listCategories: () => request('/api/admin/categories'), @@ -405,6 +447,19 @@ export const adminApi = { body: formData, }) }, + downloadMediaObject: (payload: MediaDownloadPayload) => + request('/api/admin/storage/media/download', { + method: 'POST', + body: JSON.stringify({ + source_url: payload.sourceUrl, + prefix: payload.prefix, + title: payload.title, + alt_text: payload.altText, + caption: payload.caption, + tags: payload.tags, + notes: payload.notes, + }), + }), updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) => request('/api/admin/storage/media/metadata', { method: 'PATCH', diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index 28f072e..878effe 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -125,6 +125,79 @@ export interface SubscriptionDigestResponse { skipped: number } +export interface WorkerCatalogEntry { + worker_name: string + job_kind: string + label: string + description: string + queue_name: string | null + supports_cancel: boolean + supports_retry: boolean +} + +export interface WorkerStats { + worker_name: string + job_kind: string + label: string + queued: number + running: number + succeeded: number + failed: number + cancelled: number + last_job_at: string | null +} + +export interface WorkerOverview { + total_jobs: number + queued: number + running: number + succeeded: number + failed: number + cancelled: number + active_jobs: number + worker_stats: WorkerStats[] + catalog: WorkerCatalogEntry[] +} + +export interface WorkerJobRecord { + created_at: string + updated_at: string + id: number + parent_job_id: number | null + job_kind: string + worker_name: string + display_name: string | null + status: string + queue_name: string | null + requested_by: string | null + requested_source: string | null + trigger_mode: string | null + payload: Record | null + result: Record | null + error_text: string | null + tags: unknown[] | Record | null + related_entity_type: string | null + related_entity_id: string | null + attempts_count: number + max_attempts: number + cancel_requested: boolean + queued_at: string | null + started_at: string | null + finished_at: string | null + can_cancel: boolean + can_retry: boolean +} + +export interface WorkerJobListResponse { + total: number + jobs: WorkerJobRecord[] +} + +export interface WorkerTaskActionResponse { + queued: boolean + job: WorkerJobRecord +} + export interface DashboardStats { total_posts: number total_comments: number @@ -533,6 +606,22 @@ export interface AdminMediaReplaceResponse { url: string } +export interface MediaDownloadPayload { + sourceUrl: string + prefix?: string | null + title?: string | null + altText?: string | null + caption?: string | null + tags?: string[] + notes?: string | null +} + +export interface AdminMediaDownloadResponse { + queued: boolean + job_id: number + status: string +} + export interface MediaAssetMetadataPayload { key: string title?: string | null diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx index 74295d4..36672bd 100644 --- a/admin/src/pages/dashboard-page.tsx +++ b/admin/src/pages/dashboard-page.tsx @@ -8,8 +8,10 @@ import { Rss, Star, Tags, + Workflow, } 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' @@ -35,7 +37,7 @@ import { formatReviewStatus, formatReviewType, } from '@/lib/admin-format' -import type { AdminDashboardResponse } from '@/lib/types' +import type { AdminDashboardResponse, WorkerOverview } from '@/lib/types' function StatCard({ label, @@ -66,6 +68,7 @@ function StatCard({ export function DashboardPage() { const [data, setData] = useState(null) + const [workerOverview, setWorkerOverview] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) @@ -75,9 +78,13 @@ export function DashboardPage() { setRefreshing(true) } - const next = await adminApi.dashboard() + const [next, nextWorkerOverview] = await Promise.all([ + adminApi.dashboard(), + adminApi.getWorkersOverview(), + ]) startTransition(() => { setData(next) + setWorkerOverview(nextWorkerOverview) }) if (showToast) { @@ -98,7 +105,7 @@ export function DashboardPage() { void loadDashboard(false) }, [loadDashboard]) - if (loading || !data) { + if (loading || !data || !workerOverview) { return (
@@ -146,6 +153,12 @@ export function DashboardPage() { note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭', icon: BrainCircuit, }, + { + label: 'Worker 活动', + value: workerOverview.active_jobs, + note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`, + icon: Workflow, + }, ] return ( @@ -314,6 +327,75 @@ export function DashboardPage() { {data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}

+ +
+
+
+

+ Worker 健康 +

+

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

+
+ +
+ +
+ +
Queued
+
{workerOverview.queued}
+ + +
Running
+
{workerOverview.running}
+ + +
Failed
+
{workerOverview.failed}
+ +
+ + {workerOverview.worker_stats.length ? ( +
+ {workerOverview.worker_stats.slice(0, 3).map((item) => ( + +
+
{item.label}
+
{item.worker_name}
+
+
+
Q {item.queued} · R {item.running}
+
ERR {item.failed}
+
+ + ))} +
+ ) : null} +
diff --git a/admin/src/pages/media-page.tsx b/admin/src/pages/media-page.tsx index 85e2f56..7fd2eba 100644 --- a/admin/src/pages/media-page.tsx +++ b/admin/src/pages/media-page.tsx @@ -1,6 +1,7 @@ import { CheckSquare, Copy, + Download, Image as ImageIcon, RefreshCcw, Replace, @@ -10,6 +11,7 @@ import { Upload, } from 'lucide-react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' @@ -58,6 +60,24 @@ const defaultMetadataForm: MediaMetadataFormState = { notes: '', } +type RemoteDownloadFormState = { + sourceUrl: string + title: string + altText: string + caption: string + tags: string + notes: string +} + +const defaultRemoteDownloadForm: RemoteDownloadFormState = { + sourceUrl: '', + title: '', + altText: '', + caption: '', + tags: '', + notes: '', +} + function normalizeMediaTags(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -121,6 +141,11 @@ export function MediaPage() { const [metadataSaving, setMetadataSaving] = useState(false) const [compressBeforeUpload, setCompressBeforeUpload] = useState(true) const [compressQuality, setCompressQuality] = useState('0.82') + const [remoteDownloadForm, setRemoteDownloadForm] = useState( + defaultRemoteDownloadForm, + ) + const [downloadingRemote, setDownloadingRemote] = useState(false) + const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState(null) const loadItems = useCallback(async (showToast = false) => { try { @@ -352,6 +377,147 @@ export function MediaPage() { : ''}

) : null} + +
+
+

远程抓取到媒体库

+

+ 输入可访问的图片 / PDF 直链后,会创建异步 worker 任务;下载完成后写入当前目录前缀,并同步媒体元数据。 +

+
+ +
+ + + setRemoteDownloadForm((current) => ({ + ...current, + sourceUrl: event.target.value, + })) + } + placeholder="https://example.com/cover.webp" + /> + + + + + setRemoteDownloadForm((current) => ({ + ...current, + title: event.target.value, + })) + } + placeholder="远程抓取封面" + /> + + + + + setRemoteDownloadForm((current) => ({ + ...current, + altText: event.target.value, + })) + } + placeholder="终端风格封面图" + /> + + + + + setRemoteDownloadForm((current) => ({ + ...current, + tags: event.target.value, + })) + } + placeholder="remote, cover" + /> + + +
+ +