feat: add worker operations and fix gitea actions
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 29s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 33m13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 58s
ui-regression / playwright-regression (push) Failing after 13m24s

This commit is contained in:
2026-04-02 03:43:37 +08:00
parent ee0bec4a78
commit a516be2e91
37 changed files with 3890 additions and 879 deletions

View File

@@ -100,67 +100,27 @@ jobs:
cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
fi fi
- name: Upload frontend HTML report - name: Summarize Playwright artifact paths
if: always() if: always()
uses: actions/upload-artifact@v4 shell: bash
with: run: |
name: playwright-html-report-frontend set -euo pipefail
path: playwright-smoke/.artifacts/frontend/playwright-report
retention-days: 14
if-no-files-found: ignore
- name: Upload admin HTML report echo "Gitea Actions 当前不支持 actions/upload-artifact@v4改为直接输出产物目录"
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
- name: Upload frontend raw results for path in \
if: always() "playwright-smoke/.artifacts/frontend/playwright-report" \
uses: actions/upload-artifact@v4 "playwright-smoke/.artifacts/frontend/test-results" \
with: "playwright-smoke/.artifacts/admin/playwright-report" \
name: playwright-raw-results-frontend "playwright-smoke/.artifacts/admin/test-results"
path: playwright-smoke/.artifacts/frontend/test-results do
retention-days: 14 if [ -d "${path}" ]; then
if-no-files-found: ignore echo "- ${path}"
find "${path}" -maxdepth 2 -type f | sort | head -n 20
- name: Upload admin raw results else
if: always() echo "- ${path} (missing)"
uses: actions/upload-artifact@v4 fi
with: done
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
- 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'

View File

@@ -94,6 +94,10 @@ const SubscriptionsPage = lazy(async () => {
const mod = await import('@/pages/subscriptions-page') const mod = await import('@/pages/subscriptions-page')
return { default: mod.SubscriptionsPage } return { default: mod.SubscriptionsPage }
}) })
const WorkersPage = lazy(async () => {
const mod = await import('@/pages/workers-page')
return { default: mod.WorkersPage }
})
type SessionContextValue = { type SessionContextValue = {
session: AdminSessionResponse session: AdminSessionResponse
@@ -389,6 +393,14 @@ function AppRoutes() {
</LazyRoute> </LazyRoute>
} }
/> />
<Route
path="workers"
element={
<LazyRoute>
<WorkersPage />
</LazyRoute>
}
/>
<Route <Route
path="audit" path="audit"
element={ element={

View File

@@ -16,6 +16,7 @@ import {
Settings, Settings,
Sparkles, Sparkles,
Tags, Tags,
Workflow,
} from 'lucide-react' } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
@@ -99,6 +100,12 @@ const primaryNav = [
description: '邮件 / Webhook 推送', description: '邮件 / Webhook 推送',
icon: BellRing, icon: BellRing,
}, },
{
to: '/workers',
label: 'Workers',
description: '异步任务 / 队列控制台',
icon: Workflow,
},
{ {
to: '/audit', to: '/audit',
label: '审计', label: '审计',

View File

@@ -5,6 +5,7 @@ import type {
AdminAiProviderTestResponse, AdminAiProviderTestResponse,
AdminImageUploadResponse, AdminImageUploadResponse,
AdminMediaBatchDeleteResponse, AdminMediaBatchDeleteResponse,
AdminMediaDownloadResponse,
AdminMediaDeleteResponse, AdminMediaDeleteResponse,
AdminMediaListResponse, AdminMediaListResponse,
AdminMediaMetadataResponse, AdminMediaMetadataResponse,
@@ -36,6 +37,7 @@ import type {
MarkdownDocumentResponse, MarkdownDocumentResponse,
MarkdownImportResponse, MarkdownImportResponse,
MediaAssetMetadataPayload, MediaAssetMetadataPayload,
MediaDownloadPayload,
NotificationDeliveryRecord, NotificationDeliveryRecord,
PostPageResponse, PostPageResponse,
PostListQuery, PostListQuery,
@@ -53,6 +55,10 @@ import type {
SubscriptionPayload, SubscriptionPayload,
SubscriptionRecord, SubscriptionRecord,
SubscriptionUpdatePayload, SubscriptionUpdatePayload,
WorkerJobListResponse,
WorkerJobRecord,
WorkerOverview,
WorkerTaskActionResponse,
TagRecord, TagRecord,
TaxonomyPayload, TaxonomyPayload,
UpdateCommentPayload, UpdateCommentPayload,
@@ -236,7 +242,7 @@ export const adminApi = {
method: 'DELETE', method: 'DELETE',
}), }),
testSubscription: (id: number) => testSubscription: (id: number) =>
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', method: 'POST',
}), }),
listSubscriptionDeliveries: async (limit = 80) => listSubscriptionDeliveries: async (limit = 80) =>
@@ -248,6 +254,42 @@ export const adminApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ period }), body: JSON.stringify({ period }),
}), }),
getWorkersOverview: () => request<WorkerOverview>('/api/admin/workers/overview'),
listWorkerJobs: (query?: {
status?: string
jobKind?: string
workerName?: string
search?: string
limit?: number
}) =>
request<WorkerJobListResponse>(
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<WorkerJobRecord>(`/api/admin/workers/jobs/${id}`),
cancelWorkerJob: (id: number) =>
request<WorkerJobRecord>(`/api/admin/workers/jobs/${id}/cancel`, {
method: 'POST',
}),
retryWorkerJob: (id: number) =>
request<WorkerTaskActionResponse>(`/api/admin/workers/jobs/${id}/retry`, {
method: 'POST',
}),
runRetryDeliveriesWorker: (limit?: number) =>
request<WorkerTaskActionResponse>('/api/admin/workers/tasks/retry-deliveries', {
method: 'POST',
body: JSON.stringify({ limit }),
}),
runDigestWorker: (period: 'weekly' | 'monthly') =>
request<WorkerTaskActionResponse>('/api/admin/workers/tasks/digest', {
method: 'POST',
body: JSON.stringify({ period }),
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'), dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'), analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
listCategories: () => request<CategoryRecord[]>('/api/admin/categories'), listCategories: () => request<CategoryRecord[]>('/api/admin/categories'),
@@ -405,6 +447,19 @@ export const adminApi = {
body: formData, body: formData,
}) })
}, },
downloadMediaObject: (payload: MediaDownloadPayload) =>
request<AdminMediaDownloadResponse>('/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) => updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
request<AdminMediaMetadataResponse>('/api/admin/storage/media/metadata', { request<AdminMediaMetadataResponse>('/api/admin/storage/media/metadata', {
method: 'PATCH', method: 'PATCH',

View File

@@ -125,6 +125,79 @@ export interface SubscriptionDigestResponse {
skipped: number 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<string, unknown> | null
result: Record<string, unknown> | null
error_text: string | null
tags: unknown[] | Record<string, unknown> | 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 { export interface DashboardStats {
total_posts: number total_posts: number
total_comments: number total_comments: number
@@ -533,6 +606,22 @@ export interface AdminMediaReplaceResponse {
url: string 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 { export interface MediaAssetMetadataPayload {
key: string key: string
title?: string | null title?: string | null

View File

@@ -8,8 +8,10 @@ import {
Rss, Rss,
Star, Star,
Tags, Tags,
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 { toast } from 'sonner' import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -35,7 +37,7 @@ import {
formatReviewStatus, formatReviewStatus,
formatReviewType, formatReviewType,
} from '@/lib/admin-format' } from '@/lib/admin-format'
import type { AdminDashboardResponse } from '@/lib/types' import type { AdminDashboardResponse, WorkerOverview } from '@/lib/types'
function StatCard({ function StatCard({
label, label,
@@ -66,6 +68,7 @@ function StatCard({
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 [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
@@ -75,9 +78,13 @@ export function DashboardPage() {
setRefreshing(true) setRefreshing(true)
} }
const next = await adminApi.dashboard() const [next, nextWorkerOverview] = await Promise.all([
adminApi.dashboard(),
adminApi.getWorkersOverview(),
])
startTransition(() => { startTransition(() => {
setData(next) setData(next)
setWorkerOverview(nextWorkerOverview)
}) })
if (showToast) { if (showToast) {
@@ -98,7 +105,7 @@ export function DashboardPage() {
void loadDashboard(false) void loadDashboard(false)
}, [loadDashboard]) }, [loadDashboard])
if (loading || !data) { if (loading || !data || !workerOverview) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
@@ -146,6 +153,12 @@ export function DashboardPage() {
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭', note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
icon: BrainCircuit, icon: BrainCircuit,
}, },
{
label: 'Worker 活动',
value: workerOverview.active_jobs,
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
icon: Workflow,
},
] ]
return ( return (
@@ -314,6 +327,75 @@ export function DashboardPage() {
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'} {data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
</p> </p>
</div> </div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Worker
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{workerOverview.queued} {workerOverview.running} {workerOverview.failed}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link
to={workerOverview.failed > 0 ? '/workers?status=failed' : '/workers'}
data-testid="dashboard-worker-open"
>
</Link>
</Button>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Link
to="/workers?status=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"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Queued</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.queued}</div>
</Link>
<Link
to="/workers?status=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"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Running</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.running}</div>
</Link>
<Link
to="/workers?status=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"
>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Failed</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{workerOverview.failed}</div>
</Link>
</div>
{workerOverview.worker_stats.length ? (
<div className="mt-4 space-y-2">
{workerOverview.worker_stats.slice(0, 3).map((item) => (
<Link
key={item.worker_name}
to={`/workers?worker=${encodeURIComponent(item.worker_name)}`}
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 className="font-medium text-foreground">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
</div>
<div className="text-right text-xs text-muted-foreground">
<div>Q {item.queued} · R {item.running}</div>
<div>ERR {item.failed}</div>
</div>
</Link>
))}
</div>
) : null}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { import {
CheckSquare, CheckSquare,
Copy, Copy,
Download,
Image as ImageIcon, Image as ImageIcon,
RefreshCcw, RefreshCcw,
Replace, Replace,
@@ -10,6 +11,7 @@ import {
Upload, Upload,
} from 'lucide-react' } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
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'
@@ -58,6 +60,24 @@ const defaultMetadataForm: MediaMetadataFormState = {
notes: '', 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[] { function normalizeMediaTags(value: unknown): string[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return [] return []
@@ -121,6 +141,11 @@ export function MediaPage() {
const [metadataSaving, setMetadataSaving] = useState(false) const [metadataSaving, setMetadataSaving] = useState(false)
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true) const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82') const [compressQuality, setCompressQuality] = useState('0.82')
const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>(
defaultRemoteDownloadForm,
)
const [downloadingRemote, setDownloadingRemote] = useState(false)
const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null)
const loadItems = useCallback(async (showToast = false) => { const loadItems = useCallback(async (showToast = false) => {
try { try {
@@ -352,6 +377,147 @@ export function MediaPage() {
: ''} : ''}
</p> </p>
) : null} ) : null}
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<p className="text-xs leading-6 text-muted-foreground">
访 / PDF worker
</p>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<FormField label="远程 URL">
<Input
data-testid="media-remote-url"
value={remoteDownloadForm.sourceUrl}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
sourceUrl: event.target.value,
}))
}
placeholder="https://example.com/cover.webp"
/>
</FormField>
<FormField label="标题">
<Input
data-testid="media-remote-title"
value={remoteDownloadForm.title}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
title: event.target.value,
}))
}
placeholder="远程抓取封面"
/>
</FormField>
<FormField label="Alt 文本">
<Input
value={remoteDownloadForm.altText}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
altText: event.target.value,
}))
}
placeholder="终端风格封面图"
/>
</FormField>
<FormField label="标签">
<Input
value={remoteDownloadForm.tags}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
tags: event.target.value,
}))
}
placeholder="remote, cover"
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Caption">
<Textarea
value={remoteDownloadForm.caption}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
caption: event.target.value,
}))
}
rows={3}
placeholder="适合记录图片用途或展示位置。"
/>
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="内部备注">
<Textarea
value={remoteDownloadForm.notes}
onChange={(event) =>
setRemoteDownloadForm((current) => ({
...current,
notes: event.target.value,
}))
}
rows={3}
placeholder="可选:记录素材来源、版权说明等。"
/>
</FormField>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Button
data-testid="media-remote-download"
disabled={!remoteDownloadForm.sourceUrl.trim() || downloadingRemote}
onClick={async () => {
if (!remoteDownloadForm.sourceUrl.trim()) {
toast.error('请先填写远程 URL。')
return
}
try {
setDownloadingRemote(true)
const result = await adminApi.downloadMediaObject({
sourceUrl: remoteDownloadForm.sourceUrl.trim(),
prefix: uploadPrefix,
title: remoteDownloadForm.title.trim() || null,
altText: remoteDownloadForm.altText.trim() || null,
caption: remoteDownloadForm.caption.trim() || null,
tags: parseTagList(remoteDownloadForm.tags),
notes: remoteDownloadForm.notes.trim() || null,
})
setLastRemoteDownloadJobId(result.job_id)
toast.success(`远程抓取任务已入队:#${result.job_id}`)
setRemoteDownloadForm(defaultRemoteDownloadForm)
window.setTimeout(() => {
void loadItems(false)
}, 1500)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '远程抓取失败。')
} finally {
setDownloadingRemote(false)
}
}}
>
<Download className="h-4 w-4" />
{downloadingRemote ? '抓取中...' : '抓取远程素材'}
</Button>
{lastRemoteDownloadJobId ? (
<Button variant="outline" asChild data-testid="media-last-remote-job">
<Link to={`/workers?job=${lastRemoteDownloadJobId}`}></Link>
</Button>
) : null}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,5 +1,6 @@
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react' import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
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'
@@ -19,7 +20,7 @@ import {
} from '@/components/ui/table' } from '@/components/ui/table'
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 type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types' import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types'
const CHANNEL_OPTIONS = [ const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' }, { value: 'email', label: 'Email' },
@@ -80,6 +81,8 @@ export function SubscriptionsPage() {
const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null) const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null)
const [actioningId, setActioningId] = useState<number | null>(null) const [actioningId, setActioningId] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([])
const [lastActionJobId, setLastActionJobId] = useState<number | null>(null)
const [form, setForm] = useState(emptyForm()) const [form, setForm] = useState(emptyForm())
const loadData = useCallback(async (showToast = false) => { const loadData = useCallback(async (showToast = false) => {
@@ -87,13 +90,18 @@ export function SubscriptionsPage() {
if (showToast) { if (showToast) {
setRefreshing(true) setRefreshing(true)
} }
const [nextSubscriptions, nextDeliveries] = await Promise.all([ const [nextSubscriptions, nextDeliveries, nextWorkerJobs] = await Promise.all([
adminApi.listSubscriptions(), adminApi.listSubscriptions(),
adminApi.listSubscriptionDeliveries(), adminApi.listSubscriptionDeliveries(),
adminApi.listWorkerJobs({
workerName: 'worker.notification_delivery',
limit: 200,
}),
]) ])
startTransition(() => { startTransition(() => {
setSubscriptions(nextSubscriptions) setSubscriptions(nextSubscriptions)
setDeliveries(nextDeliveries) setDeliveries(nextDeliveries)
setWorkerJobs(nextWorkerJobs.jobs)
}) })
if (showToast) { if (showToast) {
toast.success('订阅中心已刷新。') toast.success('订阅中心已刷新。')
@@ -123,6 +131,17 @@ export function SubscriptionsPage() {
[deliveries], [deliveries],
) )
const deliveryJobMap = useMemo(() => {
const map = new Map<number, WorkerJobRecord>()
for (const item of workerJobs) {
const relatedId = Number.parseInt(String(item.related_entity_id || ''), 10)
if (Number.isFinite(relatedId) && !map.has(relatedId)) {
map.set(relatedId, item)
}
}
return map
}, [workerJobs])
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setEditingId(null) setEditingId(null)
setForm(emptyForm()) setForm(emptyForm())
@@ -192,8 +211,9 @@ export function SubscriptionsPage() {
onClick={async () => { onClick={async () => {
try { try {
setDigesting('weekly') setDigesting('weekly')
const result = await adminApi.sendSubscriptionDigest('weekly') const result = await adminApi.runDigestWorker('weekly')
toast.success(`周报已入队queued ${result.queued}skipped ${result.skipped}`) setLastActionJobId(result.job.id)
toast.success(`周报任务已入队:#${result.job.id}`)
await loadData(false) await loadData(false)
} catch (error) { } catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送周报失败。') toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
@@ -211,8 +231,9 @@ export function SubscriptionsPage() {
onClick={async () => { onClick={async () => {
try { try {
setDigesting('monthly') setDigesting('monthly')
const result = await adminApi.sendSubscriptionDigest('monthly') const result = await adminApi.runDigestWorker('monthly')
toast.success(`月报已入队queued ${result.queued}skipped ${result.skipped}`) setLastActionJobId(result.job.id)
toast.success(`月报任务已入队:#${result.job.id}`)
await loadData(false) await loadData(false)
} catch (error) { } catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送月报失败。') toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
@@ -224,6 +245,11 @@ export function SubscriptionsPage() {
<BellRing className="h-4 w-4" /> <BellRing className="h-4 w-4" />
{digesting === 'monthly' ? '入队中...' : '发送月报'} {digesting === 'monthly' ? '入队中...' : '发送月报'}
</Button> </Button>
{lastActionJobId ? (
<Button variant="outline" asChild data-testid="subscriptions-last-job">
<Link to={`/workers?job=${lastActionJobId}`}></Link>
</Button>
) : null}
</div> </div>
</div> </div>
@@ -414,8 +440,15 @@ export function SubscriptionsPage() {
onClick={async () => { onClick={async () => {
try { try {
setActioningId(item.id) setActioningId(item.id)
await adminApi.testSubscription(item.id) const result = await adminApi.testSubscription(item.id)
toast.success('测试通知已入队。') if (result.job_id) {
setLastActionJobId(result.job_id)
}
toast.success(
result.job_id
? `测试通知已入队:#${result.job_id}`
: '测试通知已入队。',
)
await loadData(false) await loadData(false)
} catch (error) { } catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。') toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
@@ -478,11 +511,14 @@ export function SubscriptionsPage() {
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead>Worker</TableHead>
<TableHead></TableHead> <TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{deliveries.map((item) => ( {deliveries.map((item) => {
const workerJob = deliveryJobMap.get(item.id)
return (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell> <TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
<TableCell> <TableCell>
@@ -504,11 +540,26 @@ export function SubscriptionsPage() {
<div>attempts: {item.attempts_count}</div> <div>attempts: {item.attempts_count}</div>
<div>next: {item.next_retry_at ?? '—'}</div> <div>next: {item.next_retry_at ?? '—'}</div>
</TableCell> </TableCell>
<TableCell>
{workerJob ? (
<Button
variant="outline"
size="sm"
asChild
data-testid={`subscription-delivery-job-${item.id}`}
>
<Link to={`/workers?job=${workerJob.id}`}>#{workerJob.id}</Link>
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground"> <TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
{item.response_text ?? '—'} {item.response_text ?? '—'}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} )
})}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>

View File

@@ -0,0 +1,529 @@
import {
LoaderCircle,
RefreshCcw,
RotateCcw,
Send,
SquareTerminal,
StopCircle,
TimerReset,
Workflow,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { WorkerJobRecord, WorkerOverview } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useSearchParams } from 'react-router-dom'
function prettyJson(value: unknown) {
if (value === null || value === undefined) {
return '—'
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function statusVariant(status: string) {
switch (status) {
case 'succeeded':
return 'success' as const
case 'running':
return 'default' as const
case 'queued':
return 'secondary' as const
case 'failed':
return 'danger' as const
case 'cancelled':
return 'warning' as const
default:
return 'outline' as const
}
}
const EMPTY_OVERVIEW: WorkerOverview = {
total_jobs: 0,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
active_jobs: 0,
worker_stats: [],
catalog: [],
}
export function WorkersPage() {
const [searchParams] = useSearchParams()
const [overview, setOverview] = useState<WorkerOverview>(EMPTY_OVERVIEW)
const [jobs, setJobs] = useState<WorkerJobRecord[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [actioning, setActioning] = useState<string | null>(null)
const [selectedJobId, setSelectedJobId] = useState<number | null>(null)
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all')
const [kindFilter, setKindFilter] = useState(searchParams.get('kind') || 'all')
const [workerFilter, setWorkerFilter] = useState(searchParams.get('worker') || 'all')
const [search, setSearch] = useState(searchParams.get('search') || '')
const requestedJobId = useMemo(() => {
const raw = Number.parseInt(searchParams.get('job') || '', 10)
return Number.isFinite(raw) ? raw : null
}, [searchParams])
useEffect(() => {
setStatusFilter(searchParams.get('status') || 'all')
setKindFilter(searchParams.get('kind') || 'all')
setWorkerFilter(searchParams.get('worker') || 'all')
setSearch(searchParams.get('search') || '')
}, [searchParams])
const loadData = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const [nextOverview, nextJobs] = await Promise.all([
adminApi.getWorkersOverview(),
adminApi.listWorkerJobs({
status: statusFilter === 'all' ? undefined : statusFilter,
jobKind: kindFilter === 'all' ? undefined : kindFilter,
workerName: workerFilter === 'all' ? undefined : workerFilter,
search: search.trim() || undefined,
limit: 120,
}),
])
let nextJobList = nextJobs.jobs
if (requestedJobId && !nextJobList.some((item) => item.id === requestedJobId)) {
try {
const requestedJob = await adminApi.getWorkerJob(requestedJobId)
nextJobList = [requestedJob, ...nextJobList]
} catch {
// ignore deep-link miss and keep current list
}
}
startTransition(() => {
setOverview(nextOverview)
setJobs(nextJobList)
setTotal(nextJobs.total)
})
if (showToast) {
toast.success('Worker 管理面板已刷新。')
}
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法加载 worker 面板。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [kindFilter, requestedJobId, search, statusFilter, workerFilter])
useEffect(() => {
void loadData(false)
}, [loadData])
useEffect(() => {
const timer = window.setInterval(() => {
void loadData(false)
}, 5000)
return () => window.clearInterval(timer)
}, [loadData])
useEffect(() => {
setSelectedJobId((current) => {
if (requestedJobId) {
return requestedJobId
}
if (current && jobs.some((item) => item.id === current)) {
return current
}
return jobs[0]?.id ?? null
})
}, [jobs, requestedJobId])
const selectedJob = useMemo(
() => jobs.find((item) => item.id === selectedJobId) ?? null,
[jobs, selectedJobId],
)
const runTask = useCallback(async (task: 'weekly' | 'monthly' | 'retry') => {
try {
setActioning(task)
const result =
task === 'retry'
? await adminApi.runRetryDeliveriesWorker(80)
: await adminApi.runDigestWorker(task)
toast.success(`已入队:#${result.job.id} ${result.job.display_name ?? result.job.worker_name}`)
await loadData(false)
setSelectedJobId(result.job.id)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '任务入队失败。')
} finally {
setActioning(null)
}
}, [loadData])
const workerOptions = overview.catalog.map((item) => ({
value: item.worker_name,
label: item.label,
}))
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[760px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary">Workers / Queue</Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> Worker </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
digest /
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="secondary"
data-testid="workers-run-weekly"
disabled={actioning !== null}
onClick={() => void runTask('weekly')}
>
<Send className="h-4 w-4" />
{actioning === 'weekly' ? '入队中...' : '发送周报'}
</Button>
<Button
data-testid="workers-run-monthly"
disabled={actioning !== null}
onClick={() => void runTask('monthly')}
>
<Workflow className="h-4 w-4" />
{actioning === 'monthly' ? '入队中...' : '发送月报'}
</Button>
<Button
variant="outline"
data-testid="workers-run-retry"
disabled={actioning !== null}
onClick={() => void runTask('retry')}
>
<TimerReset className="h-4 w-4" />
{actioning === 'retry' ? '处理中...' : '重试待投递'}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
{[
{ 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 (
<Card key={item.label}>
<CardContent className="flex items-center justify-between gap-4 p-5">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{item.label}</div>
<div className="mt-2 text-3xl font-semibold tracking-tight">{item.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.hint}</div>
</div>
<div className="rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
)
})}
</div>
<Card>
<CardHeader>
<CardTitle>Worker </CardTitle>
<CardDescription> worker / task </CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{overview.worker_stats.map((item) => (
<button
key={item.worker_name}
type="button"
className={cn(
'rounded-3xl border border-border/70 bg-background/50 p-4 text-left transition hover:border-primary/30 hover:bg-primary/5',
workerFilter === item.worker_name && 'border-primary/40 bg-primary/10',
)}
onClick={() => setWorkerFilter((current) => (current === item.worker_name ? 'all' : item.worker_name))}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.worker_name}</div>
</div>
<Badge variant="outline">{item.job_kind}</Badge>
</div>
<div className="mt-4 grid grid-cols-5 gap-2 text-center text-xs">
{[
['Q', item.queued],
['R', item.running],
['OK', item.succeeded],
['ERR', item.failed],
['X', item.cancelled],
].map(([label, value]) => (
<div key={String(label)} className="rounded-2xl border border-border/70 px-2 py-2">
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
<div className="mt-1 text-base font-semibold text-foreground">{value}</div>
</div>
))}
</div>
<div className="mt-3 text-xs text-muted-foreground">
{item.last_job_at ?? '—'}
</div>
</button>
))}
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[1.4fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> {total} 120 </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[180px_180px_220px_1fr]">
<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="all"></option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="succeeded">succeeded</option>
<option value="failed">failed</option>
<option value="cancelled">cancelled</option>
</Select>
<Select value={kindFilter} onChange={(event) => setKindFilter(event.target.value)}>
<option value="all"></option>
<option value="worker">worker</option>
<option value="task">task</option>
</Select>
<Select value={workerFilter} onChange={(event) => setWorkerFilter(event.target.value)}>
<option value="all"> worker</option>
{workerOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
<Input
data-testid="workers-search"
placeholder="按 worker / display / entity 搜索"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((item) => (
<TableRow
key={item.id}
data-testid={`worker-job-row-${item.id}`}
className={cn(
'cursor-pointer',
selectedJobId === item.id && 'bg-primary/5',
)}
onClick={() => setSelectedJobId(item.id)}
>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium text-foreground">{item.display_name ?? item.worker_name}</div>
<div className="text-xs text-muted-foreground">{item.worker_name}</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
{item.cancel_requested ? <div className="text-[11px] text-amber-600">cancel requested</div> : null}
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-xs text-muted-foreground">
<div>{item.job_kind}</div>
<div>{item.requested_by ?? 'system'}</div>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.related_entity_type && item.related_entity_id
? `${item.related_entity_type}:${item.related_entity_id}`
: '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div>{item.queued_at ?? item.created_at}</div>
<div>done: {item.finished_at ?? '—'}</div>
</TableCell>
</TableRow>
))}
{!jobs.length ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> payload / result / error</CardDescription>
</CardHeader>
<CardContent>
{selectedJob ? (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{selectedJob.id}</Badge>
<Badge variant={statusVariant(selectedJob.status)}>{selectedJob.status}</Badge>
<Badge variant="secondary">{selectedJob.job_kind}</Badge>
</div>
<div>
<h3 className="text-xl font-semibold tracking-tight text-foreground">
{selectedJob.display_name ?? selectedJob.worker_name}
</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{selectedJob.worker_name}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
['请求人', 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]) => (
<div key={String(label)} className="rounded-2xl border border-border/70 bg-background/50 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{label}</div>
<div className="mt-2 text-sm text-foreground break-all">{value}</div>
</div>
))}
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={!selectedJob.can_cancel || actioning === `cancel-${selectedJob.id}`}
data-testid="workers-cancel-job"
onClick={async () => {
try {
setActioning(`cancel-${selectedJob.id}`)
const result = await adminApi.cancelWorkerJob(selectedJob.id)
toast.success(`任务 #${result.id} 已标记取消。`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '取消任务失败。')
} finally {
setActioning(null)
}
}}
>
<StopCircle className="h-4 w-4" />
{actioning === `cancel-${selectedJob.id}` ? '取消中...' : '取消任务'}
</Button>
<Button
disabled={!selectedJob.can_retry || actioning === `retry-${selectedJob.id}`}
data-testid="workers-retry-job"
onClick={async () => {
try {
setActioning(`retry-${selectedJob.id}`)
const result = await adminApi.retryWorkerJob(selectedJob.id)
toast.success(`已重跑,新的任务 #${result.job.id}`)
await loadData(false)
setSelectedJobId(result.job.id)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '重跑任务失败。')
} finally {
setActioning(null)
}
}}
>
<RotateCcw className="h-4 w-4" />
{actioning === `retry-${selectedJob.id}` ? '重跑中...' : '重跑任务'}
</Button>
</div>
<div className="space-y-3">
<div>
<div className="mb-2 text-sm font-medium text-foreground">Payload</div>
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{prettyJson(selectedJob.payload)}</pre>
</div>
<div>
<div className="mb-2 text-sm font-medium text-foreground">Result</div>
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{prettyJson(selectedJob.result)}</pre>
</div>
<div>
<div className="mb-2 text-sm font-medium text-foreground">Error</div>
<pre className="overflow-x-auto rounded-3xl border border-border/70 bg-background/60 p-4 text-xs leading-6 text-muted-foreground">{selectedJob.error_text ?? '—'}</pre>
</div>
</div>
</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 bg-background/50 px-5 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.7
FROM rust:1.94-trixie AS chef FROM rust:1.94-trixie AS chef
RUN cargo install cargo-chef --locked RUN cargo install cargo-chef --locked
WORKDIR /app WORKDIR /app

View File

@@ -43,6 +43,7 @@ mod m20260401_000032_add_runtime_security_keys_to_site_settings;
mod m20260401_000033_add_taxonomy_metadata_and_media_assets; mod m20260401_000033_add_taxonomy_metadata_and_media_assets;
mod m20260401_000034_add_source_markdown_to_posts; mod m20260401_000034_add_source_markdown_to_posts;
mod m20260401_000035_add_human_verification_modes_to_site_settings; mod m20260401_000035_add_human_verification_modes_to_site_settings;
mod m20260402_000036_create_worker_jobs;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -90,6 +91,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260401_000033_add_taxonomy_metadata_and_media_assets::Migration), Box::new(m20260401_000033_add_taxonomy_metadata_and_media_assets::Migration),
Box::new(m20260401_000034_add_source_markdown_to_posts::Migration), Box::new(m20260401_000034_add_source_markdown_to_posts::Migration),
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration), Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
Box::new(m20260402_000036_create_worker_jobs::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,98 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_table(
manager,
"worker_jobs",
&[
("id", ColType::PkAuto),
("parent_job_id", ColType::IntegerNull),
("job_kind", ColType::String),
("worker_name", ColType::String),
("display_name", ColType::StringNull),
("status", ColType::String),
("queue_name", ColType::StringNull),
("requested_by", ColType::StringNull),
("requested_source", ColType::StringNull),
("trigger_mode", ColType::StringNull),
("payload", ColType::JsonBinaryNull),
("result", ColType::JsonBinaryNull),
("error_text", ColType::TextNull),
("tags", ColType::JsonBinaryNull),
("related_entity_type", ColType::StringNull),
("related_entity_id", ColType::StringNull),
("attempts_count", ColType::Integer),
("max_attempts", ColType::Integer),
("cancel_requested", ColType::Boolean),
("queued_at", ColType::StringNull),
("started_at", ColType::StringNull),
("finished_at", ColType::StringNull),
],
&[],
)
.await?;
for (name, columns) in [
(
"idx_worker_jobs_status_created_at",
vec![Alias::new("status"), Alias::new("created_at")],
),
(
"idx_worker_jobs_worker_status_created_at",
vec![
Alias::new("worker_name"),
Alias::new("status"),
Alias::new("created_at"),
],
),
(
"idx_worker_jobs_kind_created_at",
vec![Alias::new("job_kind"), Alias::new("created_at")],
),
(
"idx_worker_jobs_related_entity",
vec![Alias::new("related_entity_type"), Alias::new("related_entity_id")],
),
(
"idx_worker_jobs_parent_job_id",
vec![Alias::new("parent_job_id")],
),
] {
let mut statement = Index::create();
statement.name(name).table(Alias::new("worker_jobs"));
for column in columns {
statement.col(column);
}
manager.create_index(statement.to_owned()).await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
for index_name in [
"idx_worker_jobs_parent_job_id",
"idx_worker_jobs_related_entity",
"idx_worker_jobs_kind_created_at",
"idx_worker_jobs_worker_status_created_at",
"idx_worker_jobs_status_created_at",
] {
manager
.drop_index(
Index::drop()
.name(index_name)
.table(Alias::new("worker_jobs"))
.to_owned(),
)
.await?;
}
drop_table(manager, "worker_jobs").await
}
}

View File

@@ -22,7 +22,10 @@ use crate::{
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts, ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
reviews, reviews,
}, },
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage}, services::{
admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs,
},
workers::downloader::DownloadWorkerArgs,
}; };
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -346,6 +349,30 @@ pub struct AdminMediaMetadataResponse {
pub notes: Option<String>, pub notes: Option<String>,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDownloadPayload {
pub source_url: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default)]
pub caption: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDownloadResponse {
pub queued: bool,
pub job_id: i32,
pub status: String,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery { pub struct AdminMediaListQuery {
pub prefix: Option<String>, pub prefix: Option<String>,
@@ -1457,6 +1484,55 @@ pub async fn replace_media_object(
}) })
} }
#[debug_handler]
pub async fn download_media_object(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminMediaDownloadPayload>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let worker_args = DownloadWorkerArgs {
source_url: payload.source_url.clone(),
prefix: payload.prefix.clone(),
title: payload.title.clone(),
alt_text: payload.alt_text.clone(),
caption: payload.caption.clone(),
tags: payload.tags.unwrap_or_default(),
notes: payload.notes.clone(),
job_id: None,
};
let job = worker_jobs::queue_download_job(
&ctx,
&worker_args,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"media.download",
"media",
Some(job.id.to_string()),
Some(payload.source_url.clone()),
Some(serde_json::json!({
"job_id": job.id,
"queued": true,
"source_url": payload.source_url,
})),
)
.await?;
format::json(AdminMediaDownloadResponse {
queued: true,
job_id: job.id,
status: job.status,
})
}
#[debug_handler] #[debug_handler]
pub async fn list_comment_blacklist( pub async fn list_comment_blacklist(
headers: HeaderMap, headers: HeaderMap,
@@ -1982,6 +2058,7 @@ pub fn routes() -> Routes {
"/storage/media/metadata", "/storage/media/metadata",
patch(update_media_object_metadata), patch(update_media_object_metadata),
) )
.add("/storage/media/download", post(download_media_object))
.add("/storage/media/replace", post(replace_media_object)) .add("/storage/media/replace", post(replace_media_object))
.add( .add(
"/comments/blacklist", "/comments/blacklist",

View File

@@ -13,7 +13,7 @@ use crate::{
}, },
services::{ services::{
admin_audit, backups, post_revisions as revision_service, admin_audit, backups, post_revisions as revision_service,
subscriptions as subscription_service, subscriptions as subscription_service, worker_jobs,
}, },
}; };
@@ -35,6 +35,15 @@ pub struct DeliveriesQuery {
pub limit: Option<u64>, pub limit: Option<u64>,
} }
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorkerJobsQuery {
pub status: Option<String>,
pub job_kind: Option<String>,
pub worker_name: Option<String>,
pub search: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionPayload { pub struct SubscriptionPayload {
#[serde(alias = "channelType")] #[serde(alias = "channelType")]
@@ -85,6 +94,11 @@ pub struct DigestDispatchRequest {
pub period: Option<String>, pub period: Option<String>,
} }
#[derive(Clone, Debug, Default, Deserialize)]
pub struct RetryDeliveriesRequest {
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct SiteBackupImportRequest { pub struct SiteBackupImportRequest {
pub backup: backups::SiteBackupDocument, pub backup: backups::SiteBackupDocument,
@@ -132,6 +146,12 @@ pub struct DeliveryListResponse {
pub deliveries: Vec<notification_deliveries::Model>, pub deliveries: Vec<notification_deliveries::Model>,
} }
#[derive(Clone, Debug, Serialize)]
pub struct WorkerTaskActionResponse {
pub queued: bool,
pub job: worker_jobs::WorkerJobRecord,
}
fn trim_to_option(value: Option<String>) -> Option<String> { fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| { value.and_then(|item| {
let trimmed = item.trim().to_string(); let trimmed = item.trim().to_string();
@@ -408,6 +428,13 @@ pub async fn test_subscription(
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
let delivery = subscription_service::send_test_notification(&ctx, &item).await?; let delivery = subscription_service::send_test_notification(&ctx, &item).await?;
let job = worker_jobs::find_latest_job_by_related_entity(
&ctx,
"notification_delivery",
&delivery.id.to_string(),
Some(worker_jobs::WORKER_NOTIFICATION_DELIVERY),
)
.await?;
admin_audit::log_event( admin_audit::log_event(
&ctx, &ctx,
Some(&actor), Some(&actor),
@@ -419,7 +446,12 @@ pub async fn test_subscription(
) )
.await?; .await?;
format::json(serde_json::json!({ "queued": true, "id": item.id, "delivery_id": delivery.id })) format::json(serde_json::json!({
"queued": true,
"id": item.id,
"delivery_id": delivery.id,
"job_id": job.as_ref().map(|value| value.id),
}))
} }
#[debug_handler] #[debug_handler]
@@ -450,6 +482,162 @@ pub async fn send_subscription_digest(
format::json(summary) format::json(summary)
} }
#[debug_handler]
pub async fn workers_overview(
headers: HeaderMap,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(worker_jobs::get_overview(&ctx).await?)
}
#[debug_handler]
pub async fn list_worker_jobs(
headers: HeaderMap,
Query(query): Query<WorkerJobsQuery>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(
worker_jobs::list_jobs(
&ctx,
worker_jobs::WorkerJobListQuery {
status: query.status,
job_kind: query.job_kind,
worker_name: query.worker_name,
search: query.search,
limit: query.limit,
},
)
.await?,
)
}
#[debug_handler]
pub async fn get_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
check_auth(&headers)?;
format::json(worker_jobs::get_job_record(&ctx, id).await?)
}
#[debug_handler]
pub async fn cancel_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let updated = worker_jobs::request_cancel(&ctx, id).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.cancel",
"worker_job",
Some(id.to_string()),
Some(updated.worker_name.clone()),
Some(serde_json::json!({ "status": updated.status })),
)
.await?;
format::json(updated)
}
#[debug_handler]
pub async fn retry_worker_job(
headers: HeaderMap,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let job = worker_jobs::retry_job(
&ctx,
id,
Some(actor.username.clone()),
Some(actor.source.clone()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.retry",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "source_job_id": id })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn run_retry_deliveries_job(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<RetryDeliveriesRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let job = worker_jobs::spawn_retry_deliveries_task(
&ctx,
payload.limit,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.task.retry_deliveries",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "limit": payload.limit })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler]
pub async fn run_digest_worker_job(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> {
let actor = check_auth(&headers)?;
let period = payload.period.unwrap_or_else(|| "weekly".to_string());
let job = worker_jobs::spawn_digest_task(
&ctx,
&period,
Some(actor.username.clone()),
Some(actor.source.clone()),
None,
Some("manual".to_string()),
)
.await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"worker.task.digest",
"worker_job",
Some(job.id.to_string()),
Some(job.worker_name.clone()),
Some(serde_json::json!({ "period": period })),
)
.await?;
format::json(WorkerTaskActionResponse { queued: true, job })
}
#[debug_handler] #[debug_handler]
pub async fn export_site_backup( pub async fn export_site_backup(
headers: HeaderMap, headers: HeaderMap,
@@ -481,6 +669,13 @@ pub fn routes() -> Routes {
.add("/subscriptions/digest", post(send_subscription_digest)) .add("/subscriptions/digest", post(send_subscription_digest))
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription)) .add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
.add("/subscriptions/{id}/test", post(test_subscription)) .add("/subscriptions/{id}/test", post(test_subscription))
.add("/workers/overview", get(workers_overview))
.add("/workers/jobs", get(list_worker_jobs))
.add("/workers/jobs/{id}", get(get_worker_job))
.add("/workers/jobs/{id}/cancel", post(cancel_worker_job))
.add("/workers/jobs/{id}/retry", post(retry_worker_job))
.add("/workers/tasks/retry-deliveries", post(run_retry_deliveries_job))
.add("/workers/tasks/digest", post(run_digest_worker_job))
.add("/site-backup/export", get(export_site_backup)) .add("/site-backup/export", get(export_site_backup))
.add("/site-backup/import", post(import_site_backup)) .add("/site-backup/import", post(import_site_backup))
} }

View File

@@ -7,12 +7,19 @@ use sea_orm::{EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
controllers::admin::check_auth, controllers::admin::{check_auth, resolve_admin_identity},
models::_entities::reviews::{self, Entity as ReviewEntity}, models::_entities::reviews::{self, Entity as ReviewEntity},
services::{admin_audit, storage}, services::{admin_audit, storage},
}; };
#[derive(Serialize, Deserialize, Debug)] fn is_public_review_status(status: Option<&str>) -> bool {
matches!(
status.unwrap_or_default().trim().to_ascii_lowercase().as_str(),
"published" | "completed" | "done"
)
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CreateReviewRequest { pub struct CreateReviewRequest {
pub title: String, pub title: String,
pub review_type: String, pub review_type: String,
@@ -25,7 +32,7 @@ pub struct CreateReviewRequest {
pub link_url: Option<String>, pub link_url: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UpdateReviewRequest { pub struct UpdateReviewRequest {
pub title: Option<String>, pub title: Option<String>,
pub review_type: Option<String>, pub review_type: Option<String>,
@@ -38,23 +45,30 @@ pub struct UpdateReviewRequest {
pub link_url: Option<String>, pub link_url: Option<String>,
} }
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> { pub async fn list(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
let include_private = resolve_admin_identity(&headers).is_some();
let reviews = ReviewEntity::find() let reviews = ReviewEntity::find()
.order_by_desc(reviews::Column::CreatedAt) .order_by_desc(reviews::Column::CreatedAt)
.all(&ctx.db) .all(&ctx.db)
.await?; .await?
.into_iter()
.filter(|review| include_private || is_public_review_status(review.status.as_deref()))
.collect::<Vec<_>>();
format::json(reviews) format::json(reviews)
} }
pub async fn get_one( pub async fn get_one(
headers: HeaderMap,
Path(id): Path<i32>, Path(id): Path<i32>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let include_private = resolve_admin_identity(&headers).is_some();
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
match review { match review {
Some(r) => format::json(r), Some(r) if include_private || is_public_review_status(r.status.as_deref()) => format::json(r),
Some(_) => Err(Error::NotFound),
None => Err(Error::NotFound), None => Err(Error::NotFound),
} }
} }

View File

@@ -20,3 +20,4 @@ pub mod site_settings;
pub mod subscriptions; pub mod subscriptions;
pub mod tags; pub mod tags;
pub mod users; pub mod users;
pub mod worker_jobs;

View File

@@ -18,3 +18,4 @@ pub use super::site_settings::Entity as SiteSettings;
pub use super::subscriptions::Entity as Subscriptions; pub use super::subscriptions::Entity as Subscriptions;
pub use super::tags::Entity as Tags; pub use super::tags::Entity as Tags;
pub use super::users::Entity as Users; pub use super::users::Entity as Users;
pub use super::worker_jobs::Entity as WorkerJobs;

View File

@@ -0,0 +1,43 @@
//! `SeaORM` Entity, manually maintained
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "worker_jobs")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub parent_job_id: Option<i32>,
pub job_kind: String,
pub worker_name: String,
pub display_name: Option<String>,
pub status: String,
pub queue_name: Option<String>,
pub requested_by: Option<String>,
pub requested_source: Option<String>,
pub trigger_mode: Option<String>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub payload: Option<Json>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub result: Option<Json>,
#[sea_orm(column_type = "Text", nullable)]
pub error_text: Option<String>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub tags: Option<Json>,
pub related_entity_type: Option<String>,
pub related_entity_id: Option<String>,
pub attempts_count: i32,
pub max_attempts: i32,
pub cancel_requested: bool,
pub queued_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -12,3 +12,4 @@ pub mod storage;
pub mod subscriptions; pub mod subscriptions;
pub mod turnstile; pub mod turnstile;
pub mod web_push; pub mod web_push;
pub mod worker_jobs;

View File

@@ -1,8 +1,5 @@
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use loco_rs::{ use loco_rs::prelude::*;
bgworker::BackgroundWorker,
prelude::*,
};
use reqwest::Client; use reqwest::Client;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, Order, QueryFilter, QueryOrder,
@@ -15,10 +12,7 @@ use uuid::Uuid;
use crate::{ use crate::{
mailers::subscription::SubscriptionMailer, mailers::subscription::SubscriptionMailer,
models::_entities::{notification_deliveries, posts, subscriptions}, models::_entities::{notification_deliveries, posts, subscriptions},
services::{content, web_push as web_push_service}, services::{content, web_push as web_push_service, worker_jobs},
workers::notification_delivery::{
NotificationDeliveryWorker, NotificationDeliveryWorkerArgs,
},
}; };
pub const CHANNEL_EMAIL: &str = "email"; pub const CHANNEL_EMAIL: &str = "email";
@@ -837,14 +831,16 @@ async fn update_subscription_delivery_state(
} }
async fn enqueue_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> { async fn enqueue_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()> {
match NotificationDeliveryWorker::perform_later(ctx, NotificationDeliveryWorkerArgs { delivery_id }).await { let _ = worker_jobs::queue_notification_delivery_job(
Ok(_) => Ok(()), ctx,
Err(Error::QueueProviderMissing) => process_delivery(ctx, delivery_id).await, delivery_id,
Err(error) => { None,
tracing::warn!("failed to enqueue delivery #{delivery_id}, falling back to sync processing: {error}"); Some("system".to_string()),
process_delivery(ctx, delivery_id).await None,
} Some("system".to_string()),
} )
.await?;
Ok(())
} }
pub async fn queue_direct_notification( pub async fn queue_direct_notification(

View File

@@ -0,0 +1,835 @@
use chrono::Utc;
use loco_rs::{
bgworker::BackgroundWorker,
prelude::*,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, Order,
PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{
models::_entities::{notification_deliveries, worker_jobs},
services::subscriptions,
workers::{
downloader::{DownloadWorker, DownloadWorkerArgs},
notification_delivery::{NotificationDeliveryWorker, NotificationDeliveryWorkerArgs},
},
};
pub const JOB_KIND_WORKER: &str = "worker";
pub const JOB_KIND_TASK: &str = "task";
pub const JOB_STATUS_QUEUED: &str = "queued";
pub const JOB_STATUS_RUNNING: &str = "running";
pub const JOB_STATUS_SUCCEEDED: &str = "succeeded";
pub const JOB_STATUS_FAILED: &str = "failed";
pub const JOB_STATUS_CANCELLED: &str = "cancelled";
pub const WORKER_DOWNLOAD_MEDIA: &str = "worker.download_media";
pub const WORKER_NOTIFICATION_DELIVERY: &str = "worker.notification_delivery";
pub const TASK_RETRY_DELIVERIES: &str = "task.retry_deliveries";
pub const TASK_SEND_WEEKLY_DIGEST: &str = "task.send_weekly_digest";
pub const TASK_SEND_MONTHLY_DIGEST: &str = "task.send_monthly_digest";
#[derive(Clone, Debug, Default)]
pub struct WorkerJobListQuery {
pub status: Option<String>,
pub job_kind: Option<String>,
pub worker_name: Option<String>,
pub search: Option<String>,
pub limit: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerCatalogEntry {
pub worker_name: String,
pub job_kind: String,
pub label: String,
pub description: String,
pub queue_name: Option<String>,
pub supports_cancel: bool,
pub supports_retry: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerStats {
pub worker_name: String,
pub job_kind: String,
pub label: String,
pub queued: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub cancelled: usize,
pub last_job_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerOverview {
pub total_jobs: usize,
pub queued: usize,
pub running: usize,
pub succeeded: usize,
pub failed: usize,
pub cancelled: usize,
pub active_jobs: usize,
pub worker_stats: Vec<WorkerStats>,
pub catalog: Vec<WorkerCatalogEntry>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerJobRecord {
pub created_at: String,
pub updated_at: String,
pub id: i32,
pub parent_job_id: Option<i32>,
pub job_kind: String,
pub worker_name: String,
pub display_name: Option<String>,
pub status: String,
pub queue_name: Option<String>,
pub requested_by: Option<String>,
pub requested_source: Option<String>,
pub trigger_mode: Option<String>,
pub payload: Option<Value>,
pub result: Option<Value>,
pub error_text: Option<String>,
pub tags: Option<Value>,
pub related_entity_type: Option<String>,
pub related_entity_id: Option<String>,
pub attempts_count: i32,
pub max_attempts: i32,
pub cancel_requested: bool,
pub queued_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
pub can_cancel: bool,
pub can_retry: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerJobListResult {
pub total: u64,
pub jobs: Vec<WorkerJobRecord>,
}
#[derive(Clone, Debug, Serialize)]
pub struct WorkerTaskDispatchResult {
pub queued: bool,
pub job: WorkerJobRecord,
}
#[derive(Clone, Debug)]
struct CreateWorkerJobInput {
parent_job_id: Option<i32>,
job_kind: String,
worker_name: String,
display_name: Option<String>,
queue_name: Option<String>,
requested_by: Option<String>,
requested_source: Option<String>,
trigger_mode: Option<String>,
payload: Option<Value>,
tags: Option<Value>,
related_entity_type: Option<String>,
related_entity_id: Option<String>,
max_attempts: i32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct RetryDeliveriesTaskPayload {
#[serde(default)]
limit: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct DigestTaskPayload {
period: String,
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn queue_name_for(worker_name: &str) -> Option<String> {
match worker_name {
WORKER_DOWNLOAD_MEDIA => Some("media".to_string()),
WORKER_NOTIFICATION_DELIVERY => Some("notifications".to_string()),
TASK_RETRY_DELIVERIES => Some("maintenance".to_string()),
TASK_SEND_WEEKLY_DIGEST | TASK_SEND_MONTHLY_DIGEST => Some("digests".to_string()),
_ => None,
}
}
fn label_for(worker_name: &str) -> String {
match worker_name {
WORKER_DOWNLOAD_MEDIA => "远程媒体下载".to_string(),
WORKER_NOTIFICATION_DELIVERY => "通知投递".to_string(),
TASK_RETRY_DELIVERIES => "重试待投递通知".to_string(),
TASK_SEND_WEEKLY_DIGEST => "发送周报".to_string(),
TASK_SEND_MONTHLY_DIGEST => "发送月报".to_string(),
_ => worker_name.to_string(),
}
}
fn description_for(worker_name: &str) -> String {
match worker_name {
WORKER_DOWNLOAD_MEDIA => "抓取远程图片 / PDF 到媒体库,并回写媒体元数据。".to_string(),
WORKER_NOTIFICATION_DELIVERY => "执行订阅通知、测试通知与 digest 投递。".to_string(),
TASK_RETRY_DELIVERIES => "扫描 retry_pending 的通知记录并重新入队。".to_string(),
TASK_SEND_WEEKLY_DIGEST => "根据近期内容生成周报,并为活跃订阅目标入队。".to_string(),
TASK_SEND_MONTHLY_DIGEST => "根据近期内容生成月报,并为活跃订阅目标入队。".to_string(),
_ => "后台异步任务。".to_string(),
}
}
fn tags_for(worker_name: &str) -> Value {
match worker_name {
WORKER_DOWNLOAD_MEDIA => json!(["media", "download"]),
WORKER_NOTIFICATION_DELIVERY => json!(["notifications", "delivery"]),
TASK_RETRY_DELIVERIES => json!(["maintenance", "retry"]),
TASK_SEND_WEEKLY_DIGEST => json!(["digest", "weekly"]),
TASK_SEND_MONTHLY_DIGEST => json!(["digest", "monthly"]),
_ => json!([]),
}
}
fn can_cancel_status(status: &str, cancel_requested: bool) -> bool {
!cancel_requested && matches!(status, JOB_STATUS_QUEUED | JOB_STATUS_RUNNING)
}
fn can_retry_status(status: &str) -> bool {
matches!(status, JOB_STATUS_FAILED | JOB_STATUS_CANCELLED | JOB_STATUS_SUCCEEDED)
}
fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
WorkerJobRecord {
created_at: item.created_at.to_rfc3339(),
updated_at: item.updated_at.to_rfc3339(),
id: item.id,
parent_job_id: item.parent_job_id,
job_kind: item.job_kind,
worker_name: item.worker_name,
display_name: item.display_name,
status: item.status.clone(),
queue_name: item.queue_name,
requested_by: item.requested_by,
requested_source: item.requested_source,
trigger_mode: item.trigger_mode,
payload: item.payload,
result: item.result,
error_text: item.error_text,
tags: item.tags,
related_entity_type: item.related_entity_type,
related_entity_id: item.related_entity_id,
attempts_count: item.attempts_count,
max_attempts: item.max_attempts,
cancel_requested: item.cancel_requested,
queued_at: item.queued_at,
started_at: item.started_at,
finished_at: item.finished_at,
can_cancel: can_cancel_status(&item.status, item.cancel_requested),
can_retry: can_retry_status(&item.status),
}
}
fn catalog_entries() -> Vec<WorkerCatalogEntry> {
[
(WORKER_DOWNLOAD_MEDIA, JOB_KIND_WORKER, true, true),
(WORKER_NOTIFICATION_DELIVERY, JOB_KIND_WORKER, true, true),
(TASK_RETRY_DELIVERIES, JOB_KIND_TASK, true, true),
(TASK_SEND_WEEKLY_DIGEST, JOB_KIND_TASK, true, true),
(TASK_SEND_MONTHLY_DIGEST, JOB_KIND_TASK, true, true),
]
.into_iter()
.map(|(worker_name, job_kind, supports_cancel, supports_retry)| WorkerCatalogEntry {
worker_name: worker_name.to_string(),
job_kind: job_kind.to_string(),
label: label_for(worker_name),
description: description_for(worker_name),
queue_name: queue_name_for(worker_name),
supports_cancel,
supports_retry,
})
.collect()
}
async fn create_job(ctx: &AppContext, input: CreateWorkerJobInput) -> Result<worker_jobs::Model> {
Ok(worker_jobs::ActiveModel {
parent_job_id: Set(input.parent_job_id),
job_kind: Set(input.job_kind),
worker_name: Set(input.worker_name),
display_name: Set(trim_to_option(input.display_name)),
status: Set(JOB_STATUS_QUEUED.to_string()),
queue_name: Set(trim_to_option(input.queue_name)),
requested_by: Set(trim_to_option(input.requested_by)),
requested_source: Set(trim_to_option(input.requested_source)),
trigger_mode: Set(trim_to_option(input.trigger_mode)),
payload: Set(input.payload),
result: Set(None),
error_text: Set(None),
tags: Set(input.tags),
related_entity_type: Set(trim_to_option(input.related_entity_type)),
related_entity_id: Set(trim_to_option(input.related_entity_id)),
attempts_count: Set(0),
max_attempts: Set(input.max_attempts.max(1)),
cancel_requested: Set(false),
queued_at: Set(Some(now_rfc3339())),
started_at: Set(None),
finished_at: Set(None),
..Default::default()
}
.insert(&ctx.db)
.await?)
}
async fn find_job(ctx: &AppContext, id: i32) -> Result<worker_jobs::Model> {
worker_jobs::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)
}
async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) {
let worker = DownloadWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await {
tracing::warn!("download worker execution failed: {error}");
}
}
async fn dispatch_notification_delivery(args_ctx: AppContext, args: NotificationDeliveryWorkerArgs) {
let worker = NotificationDeliveryWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await {
tracing::warn!("notification delivery worker execution failed: {error}");
}
}
async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) -> Result<()> {
match DownloadWorker::perform_later(ctx, args.clone()).await {
Ok(_) => Ok(()),
Err(Error::QueueProviderMissing) => {
tokio::spawn(dispatch_download(ctx.clone(), args));
Ok(())
}
Err(error) => {
tracing::warn!("download worker queue unavailable, falling back to local task: {error}");
tokio::spawn(dispatch_download(ctx.clone(), args));
Ok(())
}
}
}
async fn enqueue_notification_worker(
ctx: &AppContext,
args: NotificationDeliveryWorkerArgs,
) -> Result<()> {
match NotificationDeliveryWorker::perform_later(ctx, args.clone()).await {
Ok(_) => Ok(()),
Err(Error::QueueProviderMissing) => {
tokio::spawn(dispatch_notification_delivery(ctx.clone(), args));
Ok(())
}
Err(error) => {
tracing::warn!("notification worker queue unavailable, falling back to local task: {error}");
tokio::spawn(dispatch_notification_delivery(ctx.clone(), args));
Ok(())
}
}
}
async fn run_retry_deliveries_task(ctx: AppContext, job_id: i32, limit: Option<u64>) {
match begin_job_execution(&ctx, job_id).await {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
tracing::warn!("failed to start retry deliveries job #{job_id}: {error}");
return;
}
}
let result = async {
let effective_limit = limit.unwrap_or(60);
let queued = subscriptions::retry_due_deliveries(&ctx, effective_limit).await?;
mark_job_succeeded(
&ctx,
job_id,
Some(json!({
"limit": effective_limit,
"queued": queued,
})),
)
.await
}
.await;
if let Err(error) = result {
let _ = mark_job_failed(&ctx, job_id, error.to_string()).await;
}
}
async fn run_digest_task(ctx: AppContext, job_id: i32, period: String) {
match begin_job_execution(&ctx, job_id).await {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
tracing::warn!("failed to start digest job #{job_id}: {error}");
return;
}
}
let result = async {
let summary = subscriptions::send_digest(&ctx, &period).await?;
mark_job_succeeded(
&ctx,
job_id,
Some(json!({
"period": summary.period,
"post_count": summary.post_count,
"queued": summary.queued,
"skipped": summary.skipped,
})),
)
.await
}
.await;
if let Err(error) = result {
let _ = mark_job_failed(&ctx, job_id, error.to_string()).await;
}
}
pub async fn get_overview(ctx: &AppContext) -> Result<WorkerOverview> {
let items = worker_jobs::Entity::find()
.order_by(worker_jobs::Column::CreatedAt, Order::Desc)
.all(&ctx.db)
.await?;
let mut overview = WorkerOverview {
total_jobs: items.len(),
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
active_jobs: 0,
worker_stats: Vec::new(),
catalog: catalog_entries(),
};
let mut grouped = std::collections::BTreeMap::<String, WorkerStats>::new();
for item in items {
match item.status.as_str() {
JOB_STATUS_QUEUED => overview.queued += 1,
JOB_STATUS_RUNNING => overview.running += 1,
JOB_STATUS_SUCCEEDED => overview.succeeded += 1,
JOB_STATUS_FAILED => overview.failed += 1,
JOB_STATUS_CANCELLED => overview.cancelled += 1,
_ => {}
}
let entry = grouped.entry(item.worker_name.clone()).or_insert_with(|| WorkerStats {
worker_name: item.worker_name.clone(),
job_kind: item.job_kind.clone(),
label: label_for(&item.worker_name),
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
last_job_at: None,
});
match item.status.as_str() {
JOB_STATUS_QUEUED => entry.queued += 1,
JOB_STATUS_RUNNING => entry.running += 1,
JOB_STATUS_SUCCEEDED => entry.succeeded += 1,
JOB_STATUS_FAILED => entry.failed += 1,
JOB_STATUS_CANCELLED => entry.cancelled += 1,
_ => {}
}
if entry.last_job_at.is_none() {
entry.last_job_at = Some(item.created_at.to_rfc3339());
}
}
overview.active_jobs = overview.queued + overview.running;
overview.worker_stats = grouped.into_values().collect();
Ok(overview)
}
pub async fn list_jobs(ctx: &AppContext, query: WorkerJobListQuery) -> Result<WorkerJobListResult> {
let mut db_query = worker_jobs::Entity::find().order_by(worker_jobs::Column::CreatedAt, Order::Desc);
if let Some(status) = query.status.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
db_query = db_query.filter(worker_jobs::Column::Status.eq(status));
}
if let Some(job_kind) = query.job_kind.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
db_query = db_query.filter(worker_jobs::Column::JobKind.eq(job_kind));
}
if let Some(worker_name) = query.worker_name.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
db_query = db_query.filter(worker_jobs::Column::WorkerName.eq(worker_name));
}
if let Some(search) = query.search.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) {
db_query = db_query.filter(
Condition::any()
.add(worker_jobs::Column::WorkerName.contains(search.clone()))
.add(worker_jobs::Column::DisplayName.contains(search.clone()))
.add(worker_jobs::Column::RelatedEntityId.contains(search.clone()))
.add(worker_jobs::Column::RelatedEntityType.contains(search)),
);
}
let total = db_query.clone().count(&ctx.db).await?;
let limit = query.limit.unwrap_or(120);
let items = db_query.limit(limit).all(&ctx.db).await?;
Ok(WorkerJobListResult {
total,
jobs: items.into_iter().map(to_job_record).collect(),
})
}
pub async fn get_job_record(ctx: &AppContext, id: i32) -> Result<WorkerJobRecord> {
Ok(to_job_record(find_job(ctx, id).await?))
}
pub async fn find_latest_job_by_related_entity(
ctx: &AppContext,
related_entity_type: &str,
related_entity_id: &str,
worker_name: Option<&str>,
) -> Result<Option<WorkerJobRecord>> {
let mut query = worker_jobs::Entity::find()
.filter(worker_jobs::Column::RelatedEntityType.eq(related_entity_type.to_string()))
.filter(worker_jobs::Column::RelatedEntityId.eq(related_entity_id.to_string()))
.order_by(worker_jobs::Column::CreatedAt, Order::Desc);
if let Some(worker_name) = worker_name.map(str::trim).filter(|value| !value.is_empty()) {
query = query.filter(worker_jobs::Column::WorkerName.eq(worker_name.to_string()));
}
Ok(query.one(&ctx.db).await?.map(to_job_record))
}
pub async fn begin_job_execution(ctx: &AppContext, id: i32) -> Result<bool> {
let item = find_job(ctx, id).await?;
if item.status == JOB_STATUS_CANCELLED {
return Ok(false);
}
if item.cancel_requested {
finish_job_cancelled(ctx, id, Some("job cancelled before execution".to_string())).await?;
return Ok(false);
}
let attempts_count = item.attempts_count + 1;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_RUNNING.to_string());
active.started_at = Set(Some(now_rfc3339()));
active.finished_at = Set(None);
active.error_text = Set(None);
active.result = Set(None);
active.attempts_count = Set(attempts_count);
let _ = active.update(&ctx.db).await?;
Ok(true)
}
pub async fn mark_job_succeeded(ctx: &AppContext, id: i32, result: Option<Value>) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_SUCCEEDED.to_string());
active.result = Set(result);
active.error_text = Set(None);
active.finished_at = Set(Some(now_rfc3339()));
active.update(&ctx.db).await?;
Ok(())
}
pub async fn mark_job_failed(ctx: &AppContext, id: i32, error_text: String) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_FAILED.to_string());
active.error_text = Set(Some(error_text));
active.finished_at = Set(Some(now_rfc3339()));
active.update(&ctx.db).await?;
Ok(())
}
pub async fn finish_job_cancelled(
ctx: &AppContext,
id: i32,
error_text: Option<String>,
) -> Result<()> {
let item = find_job(ctx, id).await?;
let mut active = item.into_active_model();
active.status = Set(JOB_STATUS_CANCELLED.to_string());
active.cancel_requested = Set(true);
active.finished_at = Set(Some(now_rfc3339()));
if error_text.is_some() {
active.error_text = Set(error_text);
}
active.update(&ctx.db).await?;
Ok(())
}
pub async fn request_cancel(ctx: &AppContext, id: i32) -> Result<WorkerJobRecord> {
let item = find_job(ctx, id).await?;
let mut active = item.clone().into_active_model();
active.cancel_requested = Set(true);
if item.status == JOB_STATUS_QUEUED {
active.status = Set(JOB_STATUS_CANCELLED.to_string());
active.finished_at = Set(Some(now_rfc3339()));
active.error_text = Set(Some("job cancelled before start".to_string()));
}
let updated = active.update(&ctx.db).await?;
Ok(to_job_record(updated))
}
pub async fn queue_download_job(
ctx: &AppContext,
args: &DownloadWorkerArgs,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let payload = serde_json::to_value(args)?;
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_WORKER.to_string(),
worker_name: WORKER_DOWNLOAD_MEDIA.to_string(),
display_name: Some(
args.title
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| format!("download {}", args.source_url)),
),
queue_name: queue_name_for(WORKER_DOWNLOAD_MEDIA),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(WORKER_DOWNLOAD_MEDIA)),
related_entity_type: Some("media_download".to_string()),
related_entity_id: Some(args.source_url.clone()),
max_attempts: 1,
},
)
.await?;
let mut worker_args = args.clone();
worker_args.job_id = Some(job.id);
enqueue_download_worker(ctx, worker_args).await?;
get_job_record(ctx, job.id).await
}
pub async fn queue_notification_delivery_job(
ctx: &AppContext,
delivery_id: i32,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let delivery = notification_deliveries::Entity::find_by_id(delivery_id)
.one(&ctx.db)
.await?
.ok_or(Error::NotFound)?;
let base_args = NotificationDeliveryWorkerArgs {
delivery_id,
job_id: None,
};
let payload = serde_json::to_value(&base_args)?;
let display_name = format!("{}{}", delivery.event_type, delivery.target);
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_WORKER.to_string(),
worker_name: WORKER_NOTIFICATION_DELIVERY.to_string(),
display_name: Some(display_name),
queue_name: queue_name_for(WORKER_NOTIFICATION_DELIVERY),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(WORKER_NOTIFICATION_DELIVERY)),
related_entity_type: Some("notification_delivery".to_string()),
related_entity_id: Some(delivery_id.to_string()),
max_attempts: 1,
},
)
.await?;
let args = NotificationDeliveryWorkerArgs {
delivery_id,
job_id: Some(job.id),
};
enqueue_notification_worker(ctx, args).await?;
get_job_record(ctx, job.id).await
}
pub async fn spawn_retry_deliveries_task(
ctx: &AppContext,
limit: Option<u64>,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let payload = serde_json::to_value(RetryDeliveriesTaskPayload { limit })?;
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_TASK.to_string(),
worker_name: TASK_RETRY_DELIVERIES.to_string(),
display_name: Some("重试待投递通知".to_string()),
queue_name: queue_name_for(TASK_RETRY_DELIVERIES),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(TASK_RETRY_DELIVERIES)),
related_entity_type: Some("notification_delivery".to_string()),
related_entity_id: None,
max_attempts: 1,
},
)
.await?;
tokio::spawn(run_retry_deliveries_task(ctx.clone(), job.id, limit));
get_job_record(ctx, job.id).await
}
pub async fn spawn_digest_task(
ctx: &AppContext,
period: &str,
requested_by: Option<String>,
requested_source: Option<String>,
parent_job_id: Option<i32>,
trigger_mode: Option<String>,
) -> Result<WorkerJobRecord> {
let normalized_period = match period.trim().to_ascii_lowercase().as_str() {
"monthly" => "monthly",
_ => "weekly",
}
.to_string();
let payload = serde_json::to_value(DigestTaskPayload {
period: normalized_period.clone(),
})?;
let worker_name = if normalized_period == "monthly" {
TASK_SEND_MONTHLY_DIGEST
} else {
TASK_SEND_WEEKLY_DIGEST
};
let job = create_job(
ctx,
CreateWorkerJobInput {
parent_job_id,
job_kind: JOB_KIND_TASK.to_string(),
worker_name: worker_name.to_string(),
display_name: Some(if normalized_period == "monthly" {
"发送月报".to_string()
} else {
"发送周报".to_string()
}),
queue_name: queue_name_for(worker_name),
requested_by,
requested_source,
trigger_mode,
payload: Some(payload),
tags: Some(tags_for(worker_name)),
related_entity_type: Some("subscription_digest".to_string()),
related_entity_id: Some(normalized_period.clone()),
max_attempts: 1,
},
)
.await?;
tokio::spawn(run_digest_task(ctx.clone(), job.id, normalized_period));
get_job_record(ctx, job.id).await
}
pub async fn retry_job(
ctx: &AppContext,
id: i32,
requested_by: Option<String>,
requested_source: Option<String>,
) -> Result<WorkerJobRecord> {
let item = find_job(ctx, id).await?;
let payload = item.payload.clone().unwrap_or(Value::Null);
match item.worker_name.as_str() {
WORKER_DOWNLOAD_MEDIA => {
let args = serde_json::from_value::<DownloadWorkerArgs>(payload)?;
queue_download_job(
ctx,
&args,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
WORKER_NOTIFICATION_DELIVERY => {
let args = serde_json::from_value::<NotificationDeliveryWorkerArgs>(payload)?;
queue_notification_delivery_job(
ctx,
args.delivery_id,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
TASK_RETRY_DELIVERIES => {
let args = serde_json::from_value::<RetryDeliveriesTaskPayload>(payload)?;
spawn_retry_deliveries_task(
ctx,
args.limit,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
TASK_SEND_WEEKLY_DIGEST | TASK_SEND_MONTHLY_DIGEST => {
let args = serde_json::from_value::<DigestTaskPayload>(payload)?;
spawn_digest_task(
ctx,
&args.period,
requested_by,
requested_source,
Some(item.id),
Some("retry".to_string()),
)
.await
}
_ => Err(Error::BadRequest(format!("不支持重试任务:{}", item.worker_name))),
}
}

View File

@@ -1,13 +1,231 @@
use loco_rs::prelude::*; use loco_rs::prelude::*;
use reqwest::{header, redirect::Policy, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::services::{media_assets, storage, worker_jobs};
pub struct DownloadWorker { pub struct DownloadWorker {
pub ctx: AppContext, pub ctx: AppContext,
} }
#[derive(Deserialize, Debug, Serialize)] #[derive(Clone, Deserialize, Debug, Serialize)]
pub struct DownloadWorkerArgs { pub struct DownloadWorkerArgs {
pub user_guid: String, pub source_url: String,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default)]
pub caption: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub job_id: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DownloadedMediaObject {
pub key: String,
pub url: String,
pub size_bytes: i64,
pub source_url: String,
pub content_type: Option<String>,
}
fn trim_to_option(value: Option<String>) -> Option<String> {
value.and_then(|item| {
let trimmed = item.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn normalize_prefix(value: Option<String>) -> String {
value.unwrap_or_else(|| "uploads".to_string())
.trim()
.trim_matches('/')
.to_string()
}
fn derive_file_name(url: &Url) -> Option<String> {
url.path_segments()
.and_then(|segments| segments.last())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn infer_extension(file_name: Option<&str>, content_type: Option<&str>) -> Option<String> {
let from_name = file_name
.and_then(|name| name.rsplit('.').next())
.map(str::trim)
.filter(|ext| !ext.is_empty())
.map(str::to_ascii_lowercase);
if let Some(ext) = from_name
.as_deref()
.filter(|ext| ext.chars().all(|ch| ch.is_ascii_alphanumeric()) && ext.len() <= 10)
{
return Some(ext.to_string());
}
match content_type
.unwrap_or_default()
.trim()
.split(';')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"image/png" => Some("png".to_string()),
"image/jpeg" => Some("jpg".to_string()),
"image/webp" => Some("webp".to_string()),
"image/gif" => Some("gif".to_string()),
"image/avif" => Some("avif".to_string()),
"image/svg+xml" => Some("svg".to_string()),
"application/pdf" => Some("pdf".to_string()),
_ => None,
}
}
fn is_supported_content_type(value: Option<&str>) -> bool {
value
.unwrap_or_default()
.trim()
.split(';')
.next()
.map(|item| matches!(item, "image/png" | "image/jpeg" | "image/webp" | "image/gif" | "image/avif" | "image/svg+xml" | "application/pdf"))
.unwrap_or(false)
}
fn default_title(args: &DownloadWorkerArgs, file_name: Option<&str>) -> String {
trim_to_option(args.title.clone())
.or_else(|| {
file_name.map(|value| {
value
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(value)
.replace(['-', '_'], " ")
.trim()
.to_string()
})
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "remote asset".to_string())
}
fn merge_notes(notes: Option<String>, source_url: &str) -> Option<String> {
let note = notes.unwrap_or_default().trim().to_string();
let source_line = format!("source_url: {source_url}");
if note.is_empty() {
return Some(source_line);
}
if note.contains(&source_line) {
return Some(note);
}
Some(format!("{note}\n{source_line}"))
}
pub async fn download_media_to_storage(
ctx: &AppContext,
args: &DownloadWorkerArgs,
) -> Result<DownloadedMediaObject> {
let source_url = trim_to_option(Some(args.source_url.clone()))
.ok_or_else(|| Error::BadRequest("source_url 不能为空".to_string()))?;
let parsed_url = Url::parse(&source_url)
.map_err(|_| Error::BadRequest("source_url 必须是合法的绝对 URL".to_string()))?;
let client = reqwest::Client::builder()
.redirect(Policy::limited(5))
.build()
.map_err(|error| Error::BadRequest(format!("初始化下载客户端失败: {error}")))?;
let response = client
.get(parsed_url.clone())
.send()
.await
.map_err(|error| Error::BadRequest(format!("下载远程媒体失败: {error}")))?;
if !response.status().is_success() {
return Err(Error::BadRequest(format!(
"下载远程媒体失败,状态码:{}",
response.status()
)));
}
let final_url = response.url().clone();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
if !is_supported_content_type(content_type.as_deref()) {
return Err(Error::BadRequest(
"仅支持图片或 PDF 资源的远程抓取".to_string(),
));
}
let bytes = response
.bytes()
.await
.map_err(|error| Error::BadRequest(format!("读取远程媒体内容失败: {error}")))?;
if bytes.is_empty() {
return Err(Error::BadRequest("下载到的远程媒体内容为空".to_string()));
}
let file_name = derive_file_name(&final_url);
let extension = infer_extension(file_name.as_deref(), content_type.as_deref())
.ok_or_else(|| Error::BadRequest("无法识别远程媒体文件类型".to_string()))?;
let prefix = normalize_prefix(args.prefix.clone());
let object_key = storage::build_object_key(
&prefix,
&default_title(args, file_name.as_deref()),
&extension,
);
let stored = storage::upload_bytes_to_r2(
ctx,
&object_key,
bytes.to_vec(),
content_type.as_deref(),
Some("public, max-age=31536000, immutable"),
)
.await?;
media_assets::upsert_by_key(
ctx,
&stored.key,
media_assets::MediaAssetMetadataInput {
title: trim_to_option(args.title.clone())
.or_else(|| trim_to_option(Some(default_title(args, file_name.as_deref())))),
alt_text: trim_to_option(args.alt_text.clone()),
caption: trim_to_option(args.caption.clone()),
tags: (!args.tags.is_empty()).then_some(args.tags.clone()),
notes: merge_notes(args.notes.clone(), final_url.as_str()),
},
)
.await?;
Ok(DownloadedMediaObject {
key: stored.key,
url: stored.url,
size_bytes: bytes.len() as i64,
source_url: final_url.to_string(),
content_type,
})
} }
#[async_trait] #[async_trait]
@@ -15,9 +233,31 @@ impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
fn build(ctx: &AppContext) -> Self { fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() } Self { ctx: ctx.clone() }
} }
async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> {
// TODO: Some actual work goes here...
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
return Ok(());
}
match download_media_to_storage(&self.ctx, &args).await {
Ok(downloaded) => {
worker_jobs::mark_job_succeeded(
&self.ctx,
job_id,
Some(serde_json::to_value(downloaded)?),
)
.await?;
Ok(())
}
Err(error) => {
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error)
}
}
} else {
download_media_to_storage(&self.ctx, &args).await?;
Ok(()) Ok(())
} }
} }
}

View File

@@ -1,7 +1,7 @@
use loco_rs::prelude::*; use loco_rs::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::services::subscriptions; use crate::services::{subscriptions, worker_jobs};
pub struct NotificationDeliveryWorker { pub struct NotificationDeliveryWorker {
pub ctx: AppContext, pub ctx: AppContext,
@@ -10,6 +10,8 @@ pub struct NotificationDeliveryWorker {
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NotificationDeliveryWorkerArgs { pub struct NotificationDeliveryWorkerArgs {
pub delivery_id: i32, pub delivery_id: i32,
#[serde(default)]
pub job_id: Option<i32>,
} }
#[async_trait] #[async_trait]
@@ -23,6 +25,28 @@ impl BackgroundWorker<NotificationDeliveryWorkerArgs> for NotificationDeliveryWo
} }
async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> { async fn perform(&self, args: NotificationDeliveryWorkerArgs) -> Result<()> {
if let Some(job_id) = args.job_id {
if !worker_jobs::begin_job_execution(&self.ctx, job_id).await? {
return Ok(());
}
match subscriptions::process_delivery(&self.ctx, args.delivery_id).await {
Ok(_) => {
worker_jobs::mark_job_succeeded(
&self.ctx,
job_id,
Some(serde_json::json!({ "delivery_id": args.delivery_id })),
)
.await?;
Ok(())
}
Err(error) => {
worker_jobs::mark_job_failed(&self.ctx, job_id, error.to_string()).await?;
Err(error)
}
}
} else {
subscriptions::process_delivery(&self.ctx, args.delivery_id).await subscriptions::process_delivery(&self.ctx, args.delivery_id).await
} }
} }
}

View File

@@ -4,6 +4,8 @@ import CodeBlock from './CodeBlock.astro';
import ResponsiveImage from './ui/ResponsiveImage.astro'; import ResponsiveImage from './ui/ResponsiveImage.astro';
import { formatReadTime, getI18n } from '../lib/i18n'; import { formatReadTime, getI18n } from '../lib/i18n';
import { import {
buildCategoryUrl,
buildTagUrl,
getAccentVars, getAccentVars,
getCategoryTheme, getCategoryTheme,
getPostTypeColor, getPostTypeColor,
@@ -16,10 +18,10 @@ interface Props {
post: Post; post: Post;
selectedTag?: string; selectedTag?: string;
highlightTerm?: string; highlightTerm?: string;
tagHrefPrefix?: string; tagHrefPrefix?: string | null;
} }
const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = '/tags?tag=' } = Astro.props; const { post, selectedTag = '', highlightTerm = '', tagHrefPrefix = null } = Astro.props;
const { locale, t } = getI18n(Astro); const { locale, t } = getI18n(Astro);
const typeColor = getPostTypeColor(post.type); const typeColor = getPostTypeColor(post.type);
@@ -49,6 +51,8 @@ const highlightText = (value: string, query: string) => {
}; };
const normalizedSelectedTag = selectedTag.trim().toLowerCase(); const normalizedSelectedTag = selectedTag.trim().toLowerCase();
const resolveTagHref = (tag: string) =>
tagHrefPrefix ? `${tagHrefPrefix}${encodeURIComponent(tag)}` : buildTagUrl(tag);
--- ---
<article <article
@@ -77,9 +81,13 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)} {post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
</p> </p>
</div> </div>
<span class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5" style={getAccentVars(categoryTheme)}> <a
href={buildCategoryUrl(post.category)}
class="terminal-chip terminal-chip--accent shrink-0 text-xs py-1 px-2.5"
style={getAccentVars(categoryTheme)}
>
#{post.category} #{post.category}
</span> </a>
</div> </div>
<p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]" set:html={highlightText(post.description, highlightTerm)} /> <p class="relative z-10 mb-3 pl-3 text-sm leading-7 text-[var(--text-secondary)]" set:html={highlightText(post.description, highlightTerm)} />
@@ -122,7 +130,7 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
<div class="relative z-10 pl-3 flex flex-wrap gap-2"> <div class="relative z-10 pl-3 flex flex-wrap gap-2">
{post.tags?.map(tag => ( {post.tags?.map(tag => (
<a <a
href={`${tagHrefPrefix}${encodeURIComponent(tag)}`} href={resolveTagHref(tag)}
class:list={[ class:list={[
'terminal-chip text-xs py-1 px-2.5', 'terminal-chip text-xs py-1 px-2.5',
'terminal-chip--accent', 'terminal-chip--accent',

View File

@@ -129,6 +129,16 @@ const webPushPublicKey = popupSettings.webPushEnabled
</div> </div>
)} )}
<label class="subscription-popup-field">
<span class="subscription-popup-field-label">称呼</span>
<input
type="text"
name="displayName"
placeholder="怎么称呼你(可选)"
autocomplete="name"
/>
</label>
<label class="subscription-popup-field"> <label class="subscription-popup-field">
<span class="subscription-popup-field-label">邮箱地址</span> <span class="subscription-popup-field-label">邮箱地址</span>
<input <input

View File

@@ -47,6 +47,51 @@ export function resolveFileRef(ref: string): string {
return `/uploads/${ref}`; return `/uploads/${ref}`;
} }
function resolveTaxonomyToken(
value:
| string
| null
| undefined
| {
slug?: string | null;
name?: string | null;
},
): string {
if (typeof value === 'string') {
return value.trim();
}
return (value?.slug || value?.name || '').trim();
}
export function buildCategoryUrl(
value:
| string
| null
| undefined
| {
slug?: string | null;
name?: string | null;
},
): string {
const token = resolveTaxonomyToken(value);
return token ? `/categories/${encodeURIComponent(token)}` : '/categories';
}
export function buildTagUrl(
value:
| string
| null
| undefined
| {
slug?: string | null;
name?: string | null;
},
): string {
const token = resolveTaxonomyToken(value);
return token ? `/tags/${encodeURIComponent(token)}` : '/tags';
}
/** /**
* Generate a unique ID * Generate a unique ID
*/ */

View File

@@ -15,6 +15,8 @@ import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { formatReadTime, getI18n } from '../../lib/i18n'; import { formatReadTime, getI18n } from '../../lib/i18n';
import type { PopularPostHighlight } from '../../lib/types'; import type { PopularPostHighlight } from '../../lib/types';
import { import {
buildCategoryUrl,
buildTagUrl,
getAccentVars, getAccentVars,
getCategoryTheme, getCategoryTheme,
getPostTypeColor, getPostTypeColor,
@@ -206,10 +208,14 @@ const breadcrumbJsonLd = {
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span> <span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
{post.type === 'article' ? t('common.article') : t('common.tweet')} {post.type === 'article' ? t('common.article') : t('common.tweet')}
</span> </span>
<span class="terminal-chip terminal-chip--accent" style={getAccentVars(categoryTheme)}> <a
href={buildCategoryUrl(post.category)}
class="terminal-chip terminal-chip--accent"
style={getAccentVars(categoryTheme)}
>
<i class="fas fa-folder-tree"></i> <i class="fas fa-folder-tree"></i>
{post.category} {post.category}
</span> </a>
</div> </div>
</div> </div>
@@ -238,7 +244,7 @@ const breadcrumbJsonLd = {
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{post.tags.map(tag => ( {post.tags.map(tag => (
<a <a
href={`/tags?tag=${encodeURIComponent(tag)}`} href={buildTagUrl(tag)}
class="terminal-filter" class="terminal-filter"
style={getAccentVars(getTagTheme(tag))} style={getAccentVars(getTagTheme(tag))}
> >

View File

@@ -0,0 +1,173 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Category, Post } from '../../lib/types';
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
export const prerender = false;
const { slug } = Astro.params;
const { t } = getI18n(Astro);
let categories: Category[] = [];
let posts: Post[] = [];
try {
[categories, posts] = await Promise.all([api.getCategories(), api.getPosts()]);
} catch (error) {
console.error('Failed to fetch category detail data:', error);
}
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
const category =
categories.find((item) => {
return [item.slug, item.name].some(
(value) => (value || '').trim().toLowerCase() === requested,
);
}) || null;
if (!category) {
return new Response(null, { status: 404 });
}
const canonicalUrl = buildCategoryUrl(category);
if (slug && slug !== category.slug && category.slug) {
return Astro.redirect(canonicalUrl, 301);
}
const filteredPosts = posts.filter(
(post) => (post.category || '').trim().toLowerCase() === category.name.trim().toLowerCase(),
);
const categoryTheme = getCategoryTheme(category.name);
const pageTitle = category.seoTitle || `${category.name} - ${t('categories.title')}`;
const pageDescription =
category.seoDescription || category.description || t('categories.categoryPosts', { name: category.name });
const siteBaseUrl = new URL(Astro.request.url).origin;
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: pageTitle,
description: pageDescription,
url: absoluteCanonicalUrl,
about: {
'@type': 'Thing',
name: category.name,
description: category.description || pageDescription,
},
keywords: [category.name, category.slug].filter(Boolean),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Termi',
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('categories.title'),
item: new URL('/categories', siteBaseUrl).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: category.name,
item: absoluteCanonicalUrl,
},
],
},
];
---
<BaseLayout
title={pageTitle}
description={pageDescription}
ogImage={category.coverImage}
canonical={canonicalUrl}
jsonLd={jsonLd}
twitterCard={category.coverImage ? 'summary_large_image' : 'summary'}
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={`~/categories/${category.slug || category.name}`} class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command={`posts query --category "${category.name}"`} />
<div class="terminal-panel ml-4 mt-4 space-y-5" style={getAccentVars(categoryTheme)}>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-4">
<a href="/categories" class="terminal-link-arrow">
<i class="fas fa-arrow-left"></i>
<span>返回分类目录</span>
</a>
<div class="flex flex-wrap items-center gap-2">
<span class="terminal-kicker">
<i class="fas fa-folder-tree"></i>
category detail
</span>
<span class="terminal-chip terminal-chip--accent">
<i class="fas fa-folder-open"></i>
{category.slug || category.name}
</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-file-lines text-[var(--primary)]"></i>
<span>{t('common.postsCount', { count: filteredPosts.length })}</span>
</span>
{category.accentColor ? (
<span class="terminal-stat-pill">
<i class="fas fa-droplet text-[var(--primary)]"></i>
<span>{category.accentColor}</span>
</span>
) : null}
</div>
</div>
<div class="space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{category.name}</h1>
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
</div>
{category.coverImage ? (
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
<img
src={category.coverImage}
alt={category.name}
class="h-56 w-full object-cover"
/>
</div>
) : null}
</div>
</div>
<div class="px-4 pb-8">
<CommandPrompt command={`posts list --category "${category.name}"`} typing={false} />
{filteredPosts.length > 0 ? (
<div class="ml-4 mt-4 space-y-4">
{filteredPosts.map((post) => (
<PostCard post={post} />
))}
</div>
) : (
<div class="terminal-empty ml-4 mt-4">
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">{t('categories.emptyPosts')}</p>
</div>
)}
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -2,59 +2,25 @@
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro'; import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro'; import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client'; import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n'; import { getI18n } from '../../lib/i18n';
import type { Post } from '../../lib/types'; import type { Category } from '../../lib/types';
import { getAccentVars, getCategoryTheme } from '../../lib/utils'; import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
export const prerender = false; export const prerender = false;
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
let allPosts: Post[] = [];
const url = new URL(Astro.request.url);
const selectedCategoryParam = url.searchParams.get('category') || '';
const { t } = getI18n(Astro); const { t } = getI18n(Astro);
let categories: Category[] = [];
try { try {
[categories, allPosts] = await Promise.all([ categories = await api.getCategories();
api.getCategories(),
api.getPosts(),
]);
} catch (error) { } catch (error) {
console.error('Failed to fetch categories:', error); console.error('Failed to fetch categories:', error);
} }
const selectedCategoryRecord = categories.find((category) => {
const wanted = selectedCategoryParam.trim().toLowerCase();
if (!wanted) return false;
return [category.name, category.slug].some(
(value) => (value || '').trim().toLowerCase() === wanted
);
}) || null;
const selectedCategory = selectedCategoryRecord?.name || selectedCategoryParam;
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
const filteredPosts = selectedCategory
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
: [];
const categoryPromptCommand = selectedCategory
? `posts query --category "${selectedCategory}"`
: 'categories list --sort name';
const resultsPromptCommand = selectedCategory
? `posts list --category "${selectedCategory}"`
: 'posts list --group-by category';
const categoryAccentMap = Object.fromEntries(
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
const pageTitle = selectedCategoryRecord?.seoTitle || t('categories.pageTitle');
const pageDescription = selectedCategoryRecord?.seoDescription || selectedCategoryRecord?.description;
--- ---
<BaseLayout <BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
title={`${pageTitle} - Termi`}
description={pageDescription}
ogImage={selectedCategoryRecord?.coverImage}
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/categories" class="w-full"> <TerminalWindow title="~/categories" class="w-full">
<div class="mb-6 px-4"> <div class="mb-6 px-4">
@@ -79,89 +45,50 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
</span> </span>
<span class="terminal-stat-pill"> <span class="terminal-stat-pill">
<i class="fas fa-terminal text-[var(--primary)]"></i> <i class="fas fa-terminal text-[var(--primary)]"></i>
<span>{t('categories.quickJump')}</span> <span>点击进入分类专题详情页</span>
</span>
<span
id="categories-current-pill"
class:list={['terminal-stat-pill terminal-stat-pill--accent', !selectedCategory && 'hidden']}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open"></i>
<span id="categories-current-label">{selectedCategory}</span>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div class="px-4 space-y-6"> <div class="px-4">
<div class="ml-4"> <div class="ml-4">
<CommandPrompt promptId="categories-filter-prompt" command={categoryPromptCommand} typing={false} /> <CommandPrompt command="categories browse --mode detail-page" typing={false} />
{categories.length > 0 ? ( {categories.length > 0 ? (
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<a
href="/categories"
data-category-filter=""
class:list={[
'terminal-panel terminal-interactive-card group p-5',
!selectedCategory && 'is-active'
]}
>
<div class="flex items-start gap-4">
<div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border">
<i class="fas fa-layer-group text-lg"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
all
</div>
<h2 class="font-bold text-[var(--title-color)] transition-colors text-lg">
{t('articlesPage.allCategories')}
</h2>
</div>
<span class="terminal-chip text-xs py-1 px-2.5">
<span>{t('common.postsCount', { count: allPosts.length })}</span>
</span>
</div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
{t('categories.allCategoriesDescription')}
</p>
</div>
</div>
</a>
{categories.map((category) => ( {categories.map((category) => (
<a <a
href={`/categories?category=${encodeURIComponent(category.name)}`} href={buildCategoryUrl(category)}
data-category-filter={category.name} class="terminal-panel terminal-panel-accent terminal-interactive-card group p-5"
class:list={[
'terminal-panel terminal-panel-accent terminal-interactive-card group p-5',
normalizedSelectedCategory === category.name.trim().toLowerCase() && 'is-active'
]}
style={getAccentVars(getCategoryTheme(category.name))} style={getAccentVars(getCategoryTheme(category.name))}
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border"> <div class="terminal-accent-icon shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border">
<i class="fas fa-folder-open text-lg"></i> <i class="fas fa-folder-open text-lg"></i>
</div> </div>
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3 mb-2"> <div class="mb-2 flex items-start justify-between gap-3">
<div> <div class="min-w-0">
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1"> <div class="mb-1 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{category.slug || category.name} {category.slug || category.name}
</div> </div>
<h2 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg"> <h2 class="truncate text-lg font-bold text-[var(--title-color)] transition-colors group-hover:text-[var(--primary)]">
{category.name} {category.name}
</h2> </h2>
</div> </div>
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(category.name))}> <span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5">
<span>{t('common.postsCount', { count: category.count })}</span> <span>{t('common.postsCount', { count: category.count ?? 0 })}</span>
</span> </span>
</div> </div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
{t('categories.categoryPosts', { name: category.name })} <p class="line-clamp-3 text-sm leading-6 text-[var(--text-secondary)]">
{category.description || t('categories.categoryPosts', { name: category.name })}
</p> </p>
<div class="mt-4 terminal-link-arrow">
<span>查看分类专题</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</div> </div>
</div> </div>
</a> </a>
@@ -174,191 +101,7 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
</div> </div>
)} )}
</div> </div>
<div id="categories-results-wrap" class:list={['ml-4', !selectedCategory && 'hidden']}>
<CommandPrompt promptId="categories-results-prompt" command={resultsPromptCommand} typing={false} />
<div class="mt-4 terminal-panel">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p id="categories-selected-summary" class="text-sm leading-6 text-[var(--text-secondary)]">
{selectedCategory ? t('categories.selectedSummary', { name: selectedCategory, count: filteredPosts.length }) : ''}
</p>
<a id="categories-clear-btn" href="/categories" class:list={['terminal-link-arrow', !selectedCategory && 'hidden']}>
<span>{t('common.clearFilters')}</span>
<i class="fas fa-rotate-left text-xs"></i>
</a>
</div>
{selectedCategoryRecord && (selectedCategoryRecord.description || selectedCategoryRecord.coverImage) ? (
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
{selectedCategoryRecord.description ? (
<p>{selectedCategoryRecord.description}</p>
) : null}
{selectedCategoryRecord.accentColor ? (
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedCategoryRecord.accentColor}`}></span>
<span>{selectedCategoryRecord.accentColor}</span>
</div>
) : null}
</div>
{selectedCategoryRecord.coverImage ? (
<img
src={selectedCategoryRecord.coverImage}
alt={selectedCategoryRecord.name}
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
/>
) : null}
</div>
) : null}
</div>
</div>
<div class="ml-4">
{allPosts.length > 0 ? (
<div id="categories-posts-list" class:list={['divide-y divide-[var(--border-color)]', !selectedCategory && 'hidden']}>
{allPosts.map((post) => {
const matchesInitial = selectedCategory
? (post.category || '').trim().toLowerCase() === normalizedSelectedCategory
: false;
return (
<div
data-category-post
data-category-name={(post.category || '').trim().toLowerCase()}
class:list={[!matchesInitial && 'hidden']}
>
<PostCard post={post} />
</div>
);
})}
</div>
) : null}
<div id="categories-empty-state" class:list={['terminal-empty', (!selectedCategory || filteredPosts.length > 0) && 'hidden']}>
<i class="fas fa-search text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">{t('categories.emptyPosts')}</p>
</div>
</div>
</div> </div>
</TerminalWindow> </TerminalWindow>
</div> </div>
</BaseLayout> </BaseLayout>
<script
is:inline
define:vars={{
categoryAccentMap,
initialSelectedCategory: selectedCategory,
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const categoryButtons = Array.from(document.querySelectorAll('[data-category-filter]'));
const postCards = Array.from(document.querySelectorAll('[data-category-post]'));
const currentPill = document.getElementById('categories-current-pill');
const currentLabel = document.getElementById('categories-current-label');
const resultsWrap = document.getElementById('categories-results-wrap');
const selectedSummary = document.getElementById('categories-selected-summary');
const postsList = document.getElementById('categories-posts-list');
const emptyState = document.getElementById('categories-empty-state');
const clearBtn = document.getElementById('categories-clear-btn');
const t = window.__termiTranslate;
promptApi = window.__termiCommandPrompt;
const state = {
category: initialSelectedCategory || '',
};
function updatePrompts() {
const filterCommand = state.category
? `posts query --category "${state.category}"`
: 'categories list --sort name';
const resultsCommand = state.category
? `posts list --category "${state.category}"`
: 'posts list --group-by category';
promptApi?.set?.('categories-filter-prompt', filterCommand, { typing: false });
promptApi?.set?.('categories-results-prompt', resultsCommand, { typing: false });
}
function updateUrl() {
const params = new URLSearchParams();
if (state.category) params.set('category', state.category);
const nextUrl = params.toString() ? `/categories?${params.toString()}` : '/categories';
window.history.replaceState({}, '', nextUrl);
}
function syncButtons() {
const activeValue = state.category.trim().toLowerCase();
categoryButtons.forEach((button) => {
const value = (button.getAttribute('data-category-filter') || '').trim().toLowerCase();
const isActive = activeValue ? value === activeValue : value === '';
button.classList.toggle('is-active', isActive);
});
}
function applyCategoryFilter(pushHistory = true) {
const activeValue = state.category.trim().toLowerCase();
let visibleCount = 0;
postCards.forEach((card) => {
const value = (card.getAttribute('data-category-name') || '').trim().toLowerCase();
const matches = Boolean(activeValue) && value === activeValue;
card.classList.toggle('hidden', !matches);
if (matches) {
visibleCount += 1;
}
});
syncButtons();
updatePrompts();
if (currentPill && currentLabel) {
currentPill.classList.toggle('hidden', !state.category);
if (state.category) {
currentLabel.textContent = state.category;
currentPill.setAttribute('style', categoryAccentMap[String(state.category).trim().toLowerCase()] || '');
} else {
currentLabel.textContent = '';
currentPill.removeAttribute('style');
}
}
resultsWrap?.classList.toggle('hidden', !state.category);
postsList?.classList.toggle('hidden', !state.category);
emptyState?.classList.toggle('hidden', !state.category || visibleCount > 0);
clearBtn?.classList.toggle('hidden', !state.category);
if (selectedSummary) {
selectedSummary.textContent = state.category
? t('categories.selectedSummary', { name: state.category, count: visibleCount })
: '';
}
if (pushHistory) {
updateUrl();
}
}
categoryButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const nextValue = button.getAttribute('data-category-filter') || '';
const normalizedCurrent = state.category.trim().toLowerCase();
state.category = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
applyCategoryFilter();
});
});
clearBtn?.addEventListener('click', (event) => {
event.preventDefault();
state.category = '';
applyCategoryFilter();
});
applyCategoryFilter(false);
})();
</script>

View File

@@ -5,6 +5,7 @@ import CommandPrompt from '../components/ui/CommandPrompt.astro';
import FilterPill from '../components/ui/FilterPill.astro'; import FilterPill from '../components/ui/FilterPill.astro';
import PostCard from '../components/PostCard.astro'; import PostCard from '../components/PostCard.astro';
import FriendLinkCard from '../components/FriendLinkCard.astro'; import FriendLinkCard from '../components/FriendLinkCard.astro';
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
import ViewMoreLink from '../components/ui/ViewMoreLink.astro'; import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
import StatsList from '../components/StatsList.astro'; import StatsList from '../components/StatsList.astro';
import TechStackList from '../components/TechStackList.astro'; import TechStackList from '../components/TechStackList.astro';
@@ -275,6 +276,13 @@ const navLinks = [
</div> </div>
)} )}
<div class="mb-8 px-4">
<CommandPrompt command="subscriptions create --channel email" />
<div class="ml-4">
<SubscriptionSignup requestUrl={Astro.request.url} />
</div>
</div>
<div id="discover" class="mb-6 px-4"> <div id="discover" class="mb-6 px-4">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} /> <CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
<div class="ml-4 terminal-panel home-discovery-shell"> <div class="ml-4 terminal-panel home-discovery-shell">

View File

@@ -79,6 +79,14 @@ const statusLabels = {
'in-progress': t('reviews.statusInProgress'), 'in-progress': t('reviews.statusInProgress'),
dropped: t('reviews.statusDropped'), dropped: t('reviews.statusDropped'),
}; };
const buildReviewFilterUrl = (params: { type?: string; status?: string; tag?: string }) => {
const search = new URLSearchParams();
if (params.type) search.set('type', params.type);
if (params.status) search.set('status', params.status);
if (params.tag) search.set('tag', params.tag);
const query = search.toString();
return query ? `/reviews?${query}` : '/reviews';
};
const pageTitle = review const pageTitle = review
? `${review.title} | ${t('reviews.title')} | ${siteSettings.siteShortName}` ? `${review.title} | ${t('reviews.title')} | ${siteSettings.siteShortName}`
@@ -86,7 +94,8 @@ const pageTitle = review
const pageDescription = review?.description || copy.notFoundDescription; const pageDescription = review?.description || copy.notFoundDescription;
const canonical = review ? `/reviews/${review.id}` : '/reviews'; const canonical = review ? `/reviews/${review.id}` : '/reviews';
const jsonLd = review const jsonLd = review
? { ? [
{
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Review', '@type': 'Review',
name: review.title, name: review.title,
@@ -103,9 +112,36 @@ const jsonLd = review
'@type': 'CreativeWork', '@type': 'CreativeWork',
name: review.title, name: review.title,
genre: typeLabels[review.review_type] || review.review_type, genre: typeLabels[review.review_type] || review.review_type,
keywords: review.tags,
}, },
keywords: review.tags,
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(), url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
} },
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteSettings.siteUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('reviews.title'),
item: new URL('/reviews', siteSettings.siteUrl).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: review.title,
item: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
},
],
},
]
: undefined; : undefined;
--- ---
@@ -181,11 +217,25 @@ const jsonLd = review
<div class="review-detail-meta-grid"> <div class="review-detail-meta-grid">
<div> <div>
<div class="review-detail-meta-grid__label">{copy.type}</div> <div class="review-detail-meta-grid__label">{copy.type}</div>
<div>{typeLabels[review.review_type] || review.review_type}</div> <div>
<a
href={buildReviewFilterUrl({ type: review.review_type })}
class="review-detail-filter-link"
>
{typeLabels[review.review_type] || review.review_type}
</a>
</div>
</div> </div>
<div> <div>
<div class="review-detail-meta-grid__label">{copy.status}</div> <div class="review-detail-meta-grid__label">{copy.status}</div>
<div>{statusLabels[review.normalizedStatus]}</div> <div>
<a
href={buildReviewFilterUrl({ status: review.normalizedStatus })}
class="review-detail-filter-link"
>
{statusLabels[review.normalizedStatus]}
</a>
</div>
</div> </div>
<div> <div>
<div class="review-detail-meta-grid__label">{copy.reviewDate}</div> <div class="review-detail-meta-grid__label">{copy.reviewDate}</div>
@@ -203,7 +253,12 @@ const jsonLd = review
<div class="review-detail-tags mt-4"> <div class="review-detail-tags mt-4">
{review.tags.length ? ( {review.tags.length ? (
review.tags.map((tag) => ( review.tags.map((tag) => (
<span class="terminal-chip text-xs py-1 px-2.5">#{tag}</span> <a
href={`/reviews?tag=${encodeURIComponent(tag)}`}
class="terminal-chip text-xs py-1 px-2.5 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
>
#{tag}
</a>
)) ))
) : ( ) : (
<span class="text-sm text-[var(--text-secondary)]">{t('common.noData')}</span> <span class="text-sm text-[var(--text-secondary)]">{t('common.noData')}</span>
@@ -350,6 +405,21 @@ const jsonLd = review
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.review-detail-filter-link {
display: inline-flex;
align-items: center;
color: var(--text-primary);
text-decoration: none;
border-bottom: 1px dashed transparent;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.review-detail-filter-link:hover {
color: var(--primary);
border-bottom-color: color-mix(in oklab, var(--primary) 55%, transparent);
transform: translateY(-1px);
}
.review-detail-tags { .review-detail-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -10,11 +10,14 @@ import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/rev
export const prerender = false; export const prerender = false;
// Fetch reviews from backend API
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = []; let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all'; const selectedType = url.searchParams.get('type') || 'all';
const selectedStatus = url.searchParams.get('status') || 'all';
const selectedTag = url.searchParams.get('tag') || '';
const selectedQuery = url.searchParams.get('q')?.trim() || '';
const { t } = getI18n(Astro); const { t } = getI18n(Astro);
try { try {
reviews = await apiClient.getReviews(); reviews = await apiClient.getReviews();
} catch (error) { } catch (error) {
@@ -22,10 +25,32 @@ try {
} }
const parsedReviews: ParsedReview[] = reviews.map(parseReview); const parsedReviews: ParsedReview[] = reviews.map(parseReview);
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
const normalizedSelectedQuery = selectedQuery.toLowerCase();
const filteredReviews = selectedType === 'all' const filteredReviews = parsedReviews.filter((review) => {
? parsedReviews if (selectedType !== 'all' && review.review_type !== selectedType) return false;
: parsedReviews.filter(review => review.review_type === selectedType); if (selectedStatus !== 'all' && review.normalizedStatus !== selectedStatus) return false;
if (
normalizedSelectedTag &&
!review.tags.some((tag) => tag.trim().toLowerCase() === normalizedSelectedTag)
) {
return false;
}
if (normalizedSelectedQuery) {
const haystack = [
review.title,
review.description,
review.review_type,
review.status,
review.tags.join(' '),
]
.join(' ')
.toLowerCase();
if (!haystack.includes(normalizedSelectedQuery)) return false;
}
return true;
});
const stats = { const stats = {
total: filteredReviews.length, total: filteredReviews.length,
@@ -76,6 +101,20 @@ const statusColors: Record<ReviewStatus, string> = {
dropped: 'var(--text-tertiary)', dropped: 'var(--text-tertiary)',
}; };
const statusOptions = [
{ id: 'all', label: '全部状态' },
{ id: 'completed', label: t('reviews.statusCompleted') },
{ id: 'in-progress', label: t('reviews.statusInProgress') },
{ id: 'dropped', label: t('reviews.statusDropped') },
];
const tagOptions = Array.from(
parsedReviews.reduce((set, review) => {
review.tags.forEach((tag) => set.add(tag));
return set;
}, new Set<string>()),
).sort((left, right) => left.localeCompare(right));
const statCards = [ const statCards = [
{ {
id: 'total', id: 'total',
@@ -114,15 +153,41 @@ const statCards = [
barWidth: `${inProgressRatio}%`, barWidth: `${inProgressRatio}%`,
} }
]; ];
const buildReviewsUrl = ({
type = selectedType,
status = selectedStatus,
tag = selectedTag,
q = selectedQuery,
}: {
type?: string;
status?: string;
tag?: string;
q?: string;
}) => {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (status && status !== 'all') params.set('status', status);
if (tag) params.set('tag', tag);
if (q) params.set('q', q);
const query = params.toString();
return query ? `/reviews?${query}` : '/reviews';
};
const activeFilters = [
selectedType !== 'all' ? typeLabels[selectedType] || selectedType : '',
selectedStatus !== 'all'
? statusOptions.find((item) => item.id === selectedStatus)?.label || selectedStatus
: '',
selectedTag ? `#${selectedTag}` : '',
selectedQuery ? `q=${selectedQuery}` : '',
].filter(Boolean);
--- ---
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}> <Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Single Terminal Window for entire page -->
<TerminalWindow title="~/reviews" class="w-full"> <TerminalWindow title="~/reviews" class="w-full">
<div class="px-4 py-4 space-y-6"> <div class="px-4 py-4 space-y-6">
<!-- Header Section -->
<div> <div>
<CommandPrompt command="less README.md" path="~/reviews" /> <CommandPrompt command="less README.md" path="~/reviews" />
<div class="terminal-panel ml-4 mt-4"> <div class="terminal-panel ml-4 mt-4">
@@ -135,7 +200,7 @@ const statCards = [
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('reviews.title')}</h1> <h1 class="text-2xl font-bold text-[var(--title-color)]">{t('reviews.title')}</h1>
<p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]"> <p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
{t('reviews.subtitle')} {t('reviews.subtitle')}
{selectedType !== 'all' && ` · ${t('reviews.currentFilter', { type: typeLabels[selectedType] || selectedType })}`} {activeFilters.length > 0 && ` · ${activeFilters.join(' · ')}`}
</p> </p>
</div> </div>
</div> </div>
@@ -143,7 +208,7 @@ const statCards = [
</div> </div>
<div> <div>
<CommandPrompt promptId="reviews-stats-prompt" command="jq '.summary' stats.json" path="~/reviews" /> <CommandPrompt command="jq '.summary' stats.json" path="~/reviews" />
<div class="reviews-stats-grid ml-4 mt-2"> <div class="reviews-stats-grid ml-4 mt-2">
{statCards.map((card) => ( {statCards.map((card) => (
<div class="reviews-stat-card" style={`--review-stat-color: ${card.color};`}> <div class="reviews-stat-card" style={`--review-stat-color: ${card.color};`}>
@@ -155,25 +220,13 @@ const statCards = [
</div> </div>
<div class="mt-3 flex items-end justify-between gap-4"> <div class="mt-3 flex items-end justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<div <div class="reviews-stat-card__value">{card.value}</div>
id={card.id === 'total' ? 'reviews-total' : card.id === 'average' ? 'reviews-average' : card.id === 'completed' ? 'reviews-completed' : 'reviews-progress'} <div class="reviews-stat-card__detail">
class="reviews-stat-card__value" {card.id === 'average' ? '/ 5' : card.detail}
>
{card.value}
</div> </div>
<div
id={card.id === 'total' ? 'reviews-total-detail' : card.id === 'completed' ? 'reviews-completed-detail' : card.id === 'progress' ? 'reviews-progress-detail' : undefined}
class:list={[
'reviews-stat-card__detail',
card.id === 'average' && 'hidden'
]}
>
{card.id === 'average' ? '' : card.detail}
</div>
{card.id === 'average' && <div class="reviews-stat-card__detail">/ 5</div>}
</div> </div>
{card.id === 'average' && ( {card.id === 'average' && (
<div id="reviews-average-stars" class="reviews-average-stars"> <div class="reviews-average-stars">
{Array.from({ length: 5 }).map((_, index) => ( {Array.from({ length: 5 }).map((_, index) => (
<i class={`fas fa-star ${index < Math.round(Number(stats.avgRating)) ? '' : 'opacity-25'}`}></i> <i class={`fas fa-star ${index < Math.round(Number(stats.avgRating)) ? '' : 'opacity-25'}`}></i>
))} ))}
@@ -181,11 +234,7 @@ const statCards = [
)} )}
</div> </div>
<div class="reviews-stat-card__bar"> <div class="reviews-stat-card__bar">
<div <div class="reviews-stat-card__bar-fill" style={`width: ${card.barWidth};`}></div>
id={card.id === 'total' ? 'reviews-total-bar' : card.id === 'completed' ? 'reviews-completed-bar' : card.id === 'progress' ? 'reviews-progress-bar' : 'reviews-average-bar'}
class="reviews-stat-card__bar-fill"
style={`width: ${card.barWidth};`}
></div>
</div> </div>
</div> </div>
))} ))}
@@ -193,13 +242,11 @@ const statCards = [
</div> </div>
<div> <div>
<CommandPrompt promptId="reviews-filter-prompt" command="printf '%s\n' all game anime music book movie" path="~/reviews" /> <CommandPrompt command="printf '%s\n' all game anime music book movie" path="~/reviews" />
<div class="ml-4 mt-4 flex flex-wrap gap-2"> <div class="ml-4 mt-4 flex flex-wrap gap-2">
{filters.map(filter => ( {filters.map(filter => (
<FilterPill <FilterPill
href={filter.id === 'all' ? '/reviews' : `/reviews?type=${filter.id}`} href={buildReviewsUrl({ type: filter.id })}
data-review-filter={filter.id}
data-review-label={filter.name}
tone={filter.id === 'all' ? 'neutral' : filter.id === 'game' ? 'blue' : filter.id === 'book' ? 'amber' : filter.id === 'music' ? 'teal' : 'violet'} tone={filter.id === 'all' ? 'neutral' : filter.id === 'game' ? 'blue' : filter.id === 'book' ? 'amber' : filter.id === 'music' ? 'teal' : 'violet'}
active={selectedType === filter.id} active={selectedType === filter.id}
class="review-filter" class="review-filter"
@@ -213,16 +260,56 @@ const statCards = [
</div> </div>
<div> <div>
<CommandPrompt promptId="reviews-list-prompt" command="find . -maxdepth 1 -name '*.md' | sort" path="~/reviews" /> <CommandPrompt command="reviews filter --public-only" path="~/reviews" />
<div class="terminal-panel ml-4 mt-4">
<form method="GET" action="/reviews" class="reviews-filter-form">
<label class="reviews-filter-field">
<span>搜索</span>
<input type="text" name="q" value={selectedQuery} placeholder="标题 / 简介 / 标签" />
</label>
<label class="reviews-filter-field">
<span>状态</span>
<select name="status">
{statusOptions.map((option) => (
<option value={option.id} selected={selectedStatus === option.id}>{option.label}</option>
))}
</select>
</label>
<label class="reviews-filter-field">
<span>标签</span>
<select name="tag">
<option value="" selected={!selectedTag}>全部标签</option>
{tagOptions.map((tag) => (
<option value={tag} selected={selectedTag === tag}>{tag}</option>
))}
</select>
</label>
{selectedType !== 'all' ? <input type="hidden" name="type" value={selectedType} /> : null}
<div class="reviews-filter-actions">
<button type="submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-filter"></i>
<span>应用筛选</span>
</button>
<a href="/reviews" class="terminal-action-button">
<i class="fas fa-rotate-left"></i>
<span>{t('common.clearFilters')}</span>
</a>
</div>
</form>
</div>
</div>
<div>
<CommandPrompt command="find . -maxdepth 1 -name '*.md' | sort" path="~/reviews" />
<div class="ml-4 mt-2"> <div class="ml-4 mt-2">
<div class="reviews-card-grid"> <div class="reviews-card-grid">
{parsedReviews.map(review => ( {filteredReviews.map(review => (
<article <article
class="review-card terminal-panel group" class="review-card terminal-panel group"
data-review-card
data-review-type={review.review_type}
data-review-status={review.normalizedStatus}
data-review-rating={review.rating || 0}
style={`--review-accent: ${typeColors[review.review_type] || '#888'};`} style={`--review-accent: ${typeColors[review.review_type] || '#888'};`}
> >
<div class="review-card__poster"> <div class="review-card__poster">
@@ -253,7 +340,7 @@ const statCards = [
<div class="review-card__head"> <div class="review-card__head">
<div class="min-w-0"> <div class="min-w-0">
<div class="review-card__badges"> <div class="review-card__badges">
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={`--accent-color:${typeColors[review.review_type] || '#888'};--accent-rgb:${typeColors[review.review_type] === '#4285f4' ? '66 133 244' : review.review_type === 'anime' ? '255 107 107' : review.review_type === 'music' ? '0 255 157' : review.review_type === 'book' ? '245 158 11' : '155 89 182'};`}> <span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={`--accent-color:${typeColors[review.review_type] || '#888'};--accent-rgb:${review.review_type === 'game' ? '66 133 244' : review.review_type === 'anime' ? '255 107 107' : review.review_type === 'music' ? '0 255 157' : review.review_type === 'book' ? '245 158 11' : '155 89 182'};`}>
{typeLabels[review.review_type] || review.review_type} {typeLabels[review.review_type] || review.review_type}
</span> </span>
<span class="terminal-chip text-[10px] py-1 px-2" style={`color:${statusColors[review.normalizedStatus]};`}> <span class="terminal-chip text-[10px] py-1 px-2" style={`color:${statusColors[review.normalizedStatus]};`}>
@@ -306,9 +393,9 @@ const statCards = [
<div class="review-card__tags"> <div class="review-card__tags">
{review.tags.map((tag: string) => ( {review.tags.map((tag: string) => (
<span class="terminal-chip text-xs py-1 px-2.5"> <a class="terminal-chip text-xs py-1 px-2.5" href={buildReviewsUrl({ tag, status: selectedStatus, q: selectedQuery })}>
#{tag} #{tag}
</span> </a>
))} ))}
</div> </div>
</div> </div>
@@ -327,7 +414,6 @@ const statCards = [
</div> </div>
</div> </div>
<!-- Back to Home -->
<div class="pt-4 border-t border-[var(--border-color)]"> <div class="pt-4 border-t border-[var(--border-color)]">
<a href="/" class="terminal-subtle-link"> <a href="/" class="terminal-subtle-link">
<i class="fas fa-chevron-left"></i> <i class="fas fa-chevron-left"></i>
@@ -337,136 +423,6 @@ const statCards = [
</div> </div>
</TerminalWindow> </TerminalWindow>
</div> </div>
<script
is:inline
define:vars={{
reviewTypeLabels: {
game: t('reviews.typeGame'),
anime: t('reviews.typeAnime'),
music: t('reviews.typeMusic'),
book: t('reviews.typeBook'),
movie: t('reviews.typeMovie'),
all: t('reviews.typeAll'),
},
reviewsBaseSubtitle: t('reviews.subtitle'),
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const typeLabels = reviewTypeLabels;
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
const filters = Array.from(document.querySelectorAll('[data-review-filter]'));
const subtitle = document.getElementById('reviews-subtitle');
const totalEl = document.getElementById('reviews-total');
const avgEl = document.getElementById('reviews-average');
const completedEl = document.getElementById('reviews-completed');
const progressEl = document.getElementById('reviews-progress');
const totalDetailEl = document.getElementById('reviews-total-detail');
const totalBarEl = document.getElementById('reviews-total-bar');
const averageStarsEl = document.getElementById('reviews-average-stars');
const completedDetailEl = document.getElementById('reviews-completed-detail');
const completedBarEl = document.getElementById('reviews-completed-bar');
const progressDetailEl = document.getElementById('reviews-progress-detail');
const progressBarEl = document.getElementById('reviews-progress-bar');
const emptyState = document.getElementById('reviews-empty-state');
const t = window.__termiTranslate;
const baseSubtitle = reviewsBaseSubtitle;
promptApi = window.__termiCommandPrompt;
function updateReviewPrompts(type) {
const selectedType = type || 'all';
const statsCommand = selectedType === 'all'
? "jq '.summary' stats.json"
: `jq '.summary.${selectedType}' stats.json`;
const filterCommand = selectedType === 'all'
? "printf '%s\\n' all game anime music book movie"
: `printf '%s\\n' ${selectedType}`;
const listCommand = selectedType === 'all'
? "find . -maxdepth 1 -name '*.md' | sort"
: `grep -El '^type: ${selectedType}$' ./*.md`;
promptApi?.set?.('reviews-stats-prompt', statsCommand, { typing: false });
promptApi?.set?.('reviews-filter-prompt', filterCommand, { typing: false });
promptApi?.set?.('reviews-list-prompt', listCommand, { typing: false });
}
function updateFilterUi(activeType) {
filters.forEach((filter) => {
const isActive = filter.dataset.reviewFilter === activeType;
filter.classList.toggle('is-active', isActive);
});
}
function updateStats(visibleCards) {
const total = visibleCards.length;
const average = total
? (visibleCards.reduce((sum, card) => sum + Number(card.dataset.reviewRating || 0), 0) / total).toFixed(1)
: '0';
const completed = visibleCards.filter((card) => card.dataset.reviewStatus === 'completed').length;
const inProgress = visibleCards.filter((card) => card.dataset.reviewStatus === 'in-progress').length;
const highRatingCount = visibleCards.filter((card) => Number(card.dataset.reviewRating || 0) >= 4).length;
const completedRatio = total ? Math.round((completed / total) * 100) : 0;
const inProgressRatio = total ? Math.round((inProgress / total) * 100) : 0;
if (totalEl) totalEl.textContent = String(total);
if (avgEl) avgEl.textContent = average;
if (completedEl) completedEl.textContent = String(completed);
if (progressEl) progressEl.textContent = String(inProgress);
if (totalDetailEl) totalDetailEl.textContent = `≥4 ★ · ${highRatingCount}`;
if (totalBarEl) totalBarEl.style.width = `${total ? Math.max((highRatingCount / total) * 100, 12) : 12}%`;
if (completedDetailEl) completedDetailEl.textContent = `${completedRatio}%`;
if (completedBarEl) completedBarEl.style.width = `${completedRatio}%`;
if (progressDetailEl) progressDetailEl.textContent = `${inProgressRatio}%`;
if (progressBarEl) progressBarEl.style.width = `${inProgressRatio}%`;
if (averageStarsEl) {
const roundedAverage = Math.round(Number(average));
averageStarsEl.innerHTML = Array.from({ length: 5 }, (_, index) =>
`<i class="fas fa-star ${index < roundedAverage ? '' : 'opacity-25'}"></i>`
).join('');
}
}
function applyFilter(type, pushState = true) {
const visibleCards = cards.filter((card) => type === 'all' || card.dataset.reviewType === type);
cards.forEach((card) => {
card.style.display = visibleCards.includes(card) ? '' : 'none';
});
if (emptyState) {
emptyState.classList.toggle('hidden', visibleCards.length > 0);
}
updateFilterUi(type);
updateStats(visibleCards);
if (subtitle) {
subtitle.textContent = type === 'all'
? baseSubtitle
: `${baseSubtitle} · ${t('reviews.currentFilter', { type: typeLabels[type] || type })}`;
}
if (pushState) {
const nextUrl = type === 'all' ? '/reviews' : `/reviews?type=${encodeURIComponent(type)}`;
window.history.replaceState({}, '', nextUrl);
}
updateReviewPrompts(type);
}
filters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
applyFilter(filter.dataset.reviewFilter || 'all');
});
});
applyFilter(new URL(window.location.href).searchParams.get('type') || 'all', false);
})();
</script>
</Layout> </Layout>
<style> <style>
@@ -539,6 +495,43 @@ const statCards = [
color: #e0a100; color: #e0a100;
} }
.reviews-filter-form {
display: grid;
gap: 0.9rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
}
.reviews-filter-field {
display: grid;
gap: 0.45rem;
}
.reviews-filter-field span {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.reviews-filter-field input,
.reviews-filter-field select {
min-height: 2.85rem;
width: 100%;
border-radius: 0.95rem;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--title-color);
padding: 0.8rem 0.95rem;
}
.reviews-filter-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
grid-column: 1 / -1;
}
.reviews-card-grid { .reviews-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -762,14 +755,16 @@ const statCards = [
@media (max-width: 1024px) { @media (max-width: 1024px) {
.reviews-stats-grid, .reviews-stats-grid,
.reviews-card-grid { .reviews-card-grid,
.reviews-filter-form {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.reviews-stats-grid, .reviews-stats-grid,
.reviews-card-grid { .reviews-card-grid,
.reviews-filter-form {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }

View File

@@ -0,0 +1,176 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import PostCard from '../../components/PostCard.astro';
import { apiClient } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Post, Tag } from '../../lib/types';
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
export const prerender = false;
const { slug } = Astro.params;
const { t } = getI18n(Astro);
let tags: Tag[] = [];
let posts: Post[] = [];
try {
[tags, posts] = await Promise.all([apiClient.getTags(), apiClient.getPosts()]);
} catch (error) {
console.error('Failed to fetch tag detail data:', error);
}
const requested = decodeURIComponent(slug || '').trim().toLowerCase();
const tag =
tags.find((item) => {
return [item.slug, item.name].some(
(value) => (value || '').trim().toLowerCase() === requested,
);
}) || null;
if (!tag) {
return new Response(null, { status: 404 });
}
const canonicalUrl = buildTagUrl(tag);
if (slug && slug !== tag.slug && tag.slug) {
return Astro.redirect(canonicalUrl, 301);
}
const filteredPosts = posts.filter((post) =>
post.tags.some((item) => item.trim().toLowerCase() === tag.name.trim().toLowerCase()),
);
const tagTheme = getTagTheme(tag.name);
const pageTitle = tag.seoTitle || `${tag.name} - ${t('tags.title')}`;
const pageDescription = tag.seoDescription || tag.description || t('tags.selectedSummary', {
tag: tag.name,
count: filteredPosts.length,
});
const siteBaseUrl = new URL(Astro.request.url).origin;
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: pageTitle,
description: pageDescription,
url: absoluteCanonicalUrl,
about: {
'@type': 'DefinedTerm',
name: tag.name,
termCode: tag.slug || tag.name,
description: tag.description || pageDescription,
},
keywords: [tag.name, tag.slug].filter(Boolean),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Termi',
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('tags.title'),
item: new URL('/tags', siteBaseUrl).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: tag.name,
item: absoluteCanonicalUrl,
},
],
},
];
---
<BaseLayout
title={pageTitle}
description={pageDescription}
ogImage={tag.coverImage}
canonical={canonicalUrl}
jsonLd={jsonLd}
twitterCard={tag.coverImage ? 'summary_large_image' : 'summary'}
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={`~/tags/${tag.slug || tag.name}`} class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command={`posts query --tag "${tag.name}"`} />
<div class="terminal-panel ml-4 mt-4 space-y-5" style={getAccentVars(tagTheme)}>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-4">
<a href="/tags" class="terminal-link-arrow">
<i class="fas fa-arrow-left"></i>
<span>返回标签目录</span>
</a>
<div class="flex flex-wrap items-center gap-2">
<span class="terminal-kicker">
<i class="fas fa-hashtag"></i>
tag detail
</span>
<span class="terminal-chip terminal-chip--accent">
<i class="fas fa-tag"></i>
{tag.slug || tag.name}
</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-file-lines text-[var(--primary)]"></i>
<span>{t('common.postsCount', { count: filteredPosts.length })}</span>
</span>
{typeof tag.count === 'number' ? (
<span class="terminal-stat-pill">
<i class="fas fa-signal text-[var(--primary)]"></i>
<span>{tag.count}</span>
</span>
) : null}
</div>
</div>
<div class="space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{tag.name}</h1>
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
</div>
{tag.coverImage ? (
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
<img
src={tag.coverImage}
alt={tag.name}
class="h-56 w-full object-cover"
/>
</div>
) : null}
</div>
</div>
<div class="px-4 pb-8">
<CommandPrompt command={`posts list --tag "${tag.name}"`} typing={false} />
{filteredPosts.length > 0 ? (
<div class="ml-4 mt-4 space-y-4">
{filteredPosts.map((post) => (
<PostCard post={post} selectedTag={tag.name} />
))}
</div>
) : (
<div class="terminal-empty ml-4 mt-4">
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
</div>
)}
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -4,59 +4,28 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro'; import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro'; import FilterPill from '../../components/ui/FilterPill.astro';
import { apiClient } from '../../lib/api/client'; import { apiClient } from '../../lib/api/client';
import { getI18n, formatReadTime } from '../../lib/i18n'; import { getI18n } from '../../lib/i18n';
import type { Post, Tag } from '../../lib/types'; import type { Tag } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils'; import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
export const prerender = false; export const prerender = false;
// Fetch tags from backend const { t } = getI18n(Astro);
let tags: Tag[] = []; let tags: Tag[] = [];
let allPosts: Post[] = [];
const { locale, t } = getI18n(Astro);
try { try {
[tags, allPosts] = await Promise.all([ tags = await apiClient.getTags();
apiClient.getTags(),
apiClient.getPosts(),
]);
} catch (error) { } catch (error) {
console.error('Failed to fetch tags:', error); console.error('Failed to fetch tags:', error);
} }
// Get URL params
const url = new URL(Astro.request.url);
const selectedTagParam = url.searchParams.get('tag') || '';
const selectedTagRecord = tags.find((tag) => {
const wanted = selectedTagParam.trim().toLowerCase();
if (!wanted) return false;
return [tag.name, tag.slug].some((value) => (value || '').trim().toLowerCase() === wanted);
}) || null;
const selectedTag = selectedTagRecord?.name || selectedTagParam;
const selectedTagToken = selectedTag.trim().toLowerCase();
const selectedTagTheme = getTagTheme(selectedTag);
const isSelectedTag = (tag: Tag) =>
tag.name.trim().toLowerCase() === selectedTagToken || tag.slug.trim().toLowerCase() === selectedTagToken;
const filteredPosts = selectedTag
? allPosts.filter((post) => post.tags?.some((tag) => (tag || '').trim().toLowerCase() === selectedTagToken))
: [];
const tagAccentMap = Object.fromEntries(
tags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
);
const pageTitle = selectedTagRecord?.seoTitle || t('tags.pageTitle');
const pageDescription = selectedTagRecord?.seoDescription || selectedTagRecord?.description;
--- ---
<BaseLayout <BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
title={`${pageTitle} - Termi`}
description={pageDescription}
ogImage={selectedTagRecord?.coverImage}
>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/tags" class="w-full"> <TerminalWindow title="~/tags" class="w-full">
<div class="mb-6 px-4"> <div class="mb-6 px-4">
<CommandPrompt command="cut -d',' -f1 tags.index | sort -u" /> <CommandPrompt command="tags list --sort popularity" />
<div class="terminal-panel ml-4 mt-4"> <div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">tag index</div> <div class="terminal-kicker">tag index</div>
<div class="terminal-section-title mt-4"> <div class="terminal-section-title mt-4">
@@ -75,253 +44,43 @@ const pageDescription = selectedTagRecord?.seoDescription || selectedTagRecord?.
<i class="fas fa-tags text-[var(--primary)]"></i> <i class="fas fa-tags text-[var(--primary)]"></i>
<span>{t('common.tagsCount', { count: tags.length })}</span> <span>{t('common.tagsCount', { count: tags.length })}</span>
</span> </span>
<span <span class="terminal-stat-pill">
id="tags-current-pill" <i class="fas fa-terminal text-[var(--primary)]"></i>
class:list={['terminal-stat-pill terminal-stat-pill--accent', !selectedTag && 'hidden']} <span>点击进入标签详情页</span>
style={selectedTag ? getAccentVars(selectedTagTheme) : undefined}
>
<i class="fas fa-filter"></i>
<span id="tags-current-label">{selectedTag ? t('tags.currentTag', { tag: selectedTag }) : ''}</span>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div id="tags-summary-section" class:list={['mb-6 px-4', !selectedTag && 'hidden']}> <div class="px-4">
<CommandPrompt promptId="tags-match-prompt" command={selectedTag ? `grep -Ril "#${selectedTag}" ./posts` : 'grep -Ril "#tag" ./posts'} />
<div class="terminal-panel ml-4 mt-4"> <div class="terminal-panel ml-4 mt-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div class="mb-4 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
<p id="tags-selected-summary" class="text-[var(--text-secondary)] leading-6">
{t('tags.selectedSummary', { tag: selectedTag, count: filteredPosts.length })}
</p>
<a id="tags-clear-btn" href="/tags" class="ui-filter-pill ui-filter-pill--teal">
<i class="fas fa-times"></i>
<span>{t('common.clearFilters')}</span>
</a>
</div>
{selectedTagRecord && (selectedTagRecord.description || selectedTagRecord.coverImage) ? (
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
{selectedTagRecord.description ? <p>{selectedTagRecord.description}</p> : null}
{selectedTagRecord.accentColor ? (
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedTagRecord.accentColor}`}></span>
<span>{selectedTagRecord.accentColor}</span>
</div>
) : null}
</div>
{selectedTagRecord.coverImage ? (
<img
src={selectedTagRecord.coverImage}
alt={selectedTagRecord.name}
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
/>
) : null}
</div>
) : null}
</div>
</div>
<div class="px-4 mb-8">
<div class="terminal-panel ml-4 mt-4">
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-4">
{t('tags.browseTags')} {t('tags.browseTags')}
</div> </div>
<div class="flex flex-wrap gap-2">
{tags.length === 0 ? ( {tags.length === 0 ? (
<div class="terminal-empty w-full"> <div class="terminal-empty w-full">
{t('tags.emptyTags')} {t('tags.emptyTags')}
</div> </div>
) : ( ) : (
tags.map(tag => ( <div class="flex flex-wrap gap-3">
{tags.map((tag) => (
<FilterPill <FilterPill
tone="accent" tone="accent"
active={isSelectedTag(tag)} href={buildTagUrl(tag)}
href={`/tags?tag=${encodeURIComponent(tag.slug || tag.name || '')}`}
data-tag-filter={tag.slug || tag.name || ''}
style={getAccentVars(getTagTheme(tag.name))} style={getAccentVars(getTagTheme(tag.name))}
> >
<i class="fas fa-hashtag"></i> <i class="fas fa-hashtag"></i>
<span>{tag.name}</span> <span>{tag.name}</span>
{typeof tag.count === 'number' ? (
<span class="text-xs text-[var(--text-tertiary)]">{tag.count}</span>
) : null}
</FilterPill> </FilterPill>
)) ))}
</div>
)} )}
</div> </div>
</div> </div>
</div>
<div class="px-4">
<div id="tags-results-section" class:list={['border-t border-[var(--border-color)] pt-6', !selectedTag && 'hidden']}>
<CommandPrompt promptId="tags-results-prompt" command={selectedTag ? `find ./posts -type f | xargs grep -il "#${selectedTag}"` : "find ./posts -type f | sort"} />
<div class="ml-4 mt-4 space-y-4">
{allPosts.map((post) => {
const matchesInitial = selectedTag
? post.tags?.some((tag) => (tag || '').trim().toLowerCase() === selectedTagToken)
: false;
return (
<a
href={`/articles/${post.slug}`}
data-tag-post
data-tags={post.tags.map((tag) => (tag || '').trim().toLowerCase()).join('|')}
class:list={[
'terminal-panel terminal-panel-accent terminal-interactive-card block p-5',
!matchesInitial && 'hidden'
]}
style={getAccentVars(getPostTypeTheme(post.type))}
>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<h3 class="font-bold text-[var(--title-color)] text-lg">{post.title}</h3>
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(post.category))}>
<span>{post.category}</span>
</span>
</div>
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {formatReadTime(locale, post.readTime, t)}</p>
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
<div class="mt-4 terminal-link-arrow">
<span>{t('common.viewArticle')}</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</a>
);
})}
</div>
</div>
</div>
<div class="px-4">
<div id="tags-empty-wrap" class:list={['border-t border-[var(--border-color)] pt-6', (!selectedTag || filteredPosts.length > 0) && 'hidden']}>
<div id="tags-empty-state" class="terminal-empty ml-4 mt-4">
<i class="fas fa-search text-4xl text-[var(--text-tertiary)] mb-4"></i>
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
</div>
</div>
</div>
</TerminalWindow> </TerminalWindow>
</div> </div>
</BaseLayout> </BaseLayout>
<script
is:inline
define:vars={{
initialSelectedTag: selectedTag,
tagAccentMap,
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const tagButtons = Array.from(document.querySelectorAll('[data-tag-filter]'));
const tagPosts = Array.from(document.querySelectorAll('[data-tag-post]'));
const currentPill = document.getElementById('tags-current-pill');
const currentLabel = document.getElementById('tags-current-label');
const summarySection = document.getElementById('tags-summary-section');
const selectedSummary = document.getElementById('tags-selected-summary');
const resultsSection = document.getElementById('tags-results-section');
const emptyWrap = document.getElementById('tags-empty-wrap');
const clearBtn = document.getElementById('tags-clear-btn');
const t = window.__termiTranslate;
promptApi = window.__termiCommandPrompt;
const state = {
tag: initialSelectedTag || '',
};
function syncTagButtons() {
tagButtons.forEach((button) => {
const value = (button.getAttribute('data-tag-filter') || '').trim().toLowerCase();
button.classList.toggle('is-active', Boolean(state.tag) && value === state.tag.toLowerCase());
});
}
function updateTagPrompts() {
const matchCommand = state.tag
? `grep -Ril "#${state.tag}" ./posts`
: 'grep -Ril "#tag" ./posts';
const resultCommand = state.tag
? `find ./posts -type f | xargs grep -il "#${state.tag}"`
: "find ./posts -type f | sort";
promptApi?.set?.('tags-match-prompt', matchCommand, { typing: false });
promptApi?.set?.('tags-results-prompt', resultCommand, { typing: false });
}
function updateTagUrl() {
const params = new URLSearchParams();
if (state.tag) params.set('tag', state.tag);
const nextUrl = params.toString() ? `/tags?${params.toString()}` : '/tags';
window.history.replaceState({}, '', nextUrl);
}
function applyTagFilter(pushHistory = true) {
const normalizedTag = state.tag.trim().toLowerCase();
let visibleCount = 0;
tagPosts.forEach((post) => {
const tags = `|${(post.getAttribute('data-tags') || '').toLowerCase()}|`;
const matches = normalizedTag ? tags.includes(`|${normalizedTag}|`) : false;
post.classList.toggle('hidden', !matches);
if (matches) {
visibleCount += 1;
}
});
syncTagButtons();
updateTagPrompts();
if (currentPill && currentLabel) {
currentPill.classList.toggle('hidden', !state.tag);
if (state.tag) {
currentLabel.textContent = t('tags.currentTag', { tag: state.tag });
currentPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
} else {
currentLabel.textContent = '';
currentPill.removeAttribute('style');
}
}
if (summarySection) {
summarySection.classList.toggle('hidden', !state.tag);
}
if (resultsSection) {
resultsSection.classList.toggle('hidden', !state.tag);
}
if (selectedSummary) {
selectedSummary.textContent = state.tag
? t('tags.selectedSummary', { tag: state.tag, count: visibleCount })
: '';
}
if (emptyWrap) {
emptyWrap.classList.toggle('hidden', !state.tag || visibleCount > 0);
}
if (clearBtn) {
clearBtn.classList.toggle('hidden', !state.tag);
}
if (pushHistory) {
updateTagUrl();
}
}
tagButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
state.tag = button.getAttribute('data-tag-filter') || '';
applyTagFilter();
});
});
clearBtn?.addEventListener('click', (event) => {
event.preventDefault();
state.tag = '';
applyTagFilter();
});
applyTagFilter(false);
})();
</script>

View File

@@ -897,6 +897,12 @@ function buildReviewRecord(entry, id) {
} }
} }
function isPublicReviewVisible(review) {
return ['published', 'completed', 'done'].includes(
normalizeText(review?.status).toLowerCase(),
)
}
function createSubscriptionRecord(id, overrides = {}) { function createSubscriptionRecord(id, overrides = {}) {
return { return {
created_at: iso(-id * 6), created_at: iso(-id * 6),
@@ -1097,6 +1103,7 @@ function createInitialState() {
], ],
comment_persona_logs: [], comment_persona_logs: [],
deliveries: [], deliveries: [],
worker_jobs: [],
ai_events: [], ai_events: [],
content_events: [], content_events: [],
captcha_tokens: new Map(), captcha_tokens: new Map(),
@@ -1111,6 +1118,7 @@ function createInitialState() {
audit: 1, audit: 1,
revision: 1, revision: 1,
delivery: 1, delivery: 1,
worker_job: 1,
blacklist: 2, blacklist: 2,
persona_log: 1, persona_log: 1,
ai_event: 1, ai_event: 1,
@@ -1224,6 +1232,156 @@ function createNotificationDelivery({
} }
} }
function createWorkerJob({
job_kind = 'worker',
worker_name,
display_name = null,
status = 'queued',
queue_name = null,
requested_by = VALID_LOGIN.username,
requested_source = 'mock-admin',
trigger_mode = 'manual',
payload = null,
result = null,
error_text = null,
tags = [],
related_entity_type = null,
related_entity_id = null,
parent_job_id = null,
attempts_count = status === 'queued' ? 0 : 1,
max_attempts = 1,
cancel_requested = false,
queued_at = iso(-1),
started_at = status === 'queued' ? null : iso(-1),
finished_at = status === 'queued' || status === 'running' ? null : iso(-1),
} = {}) {
const record = {
created_at: iso(-1),
updated_at: iso(-1),
id: nextId('worker_job'),
parent_job_id,
job_kind,
worker_name,
display_name,
status,
queue_name,
requested_by,
requested_source,
trigger_mode,
payload,
result,
error_text,
tags: [...tags],
related_entity_type,
related_entity_id: related_entity_id === null ? null : String(related_entity_id),
attempts_count,
max_attempts,
cancel_requested,
queued_at,
started_at,
finished_at,
}
state.worker_jobs.unshift(record)
return record
}
function canCancelWorkerJob(job) {
return !job.cancel_requested && (job.status === 'queued' || job.status === 'running')
}
function canRetryWorkerJob(job) {
return ['failed', 'cancelled', 'succeeded'].includes(job.status)
}
function normalizeWorkerJob(job) {
return {
...clone(job),
can_cancel: canCancelWorkerJob(job),
can_retry: canRetryWorkerJob(job),
}
}
function buildWorkerOverview() {
const jobs = state.worker_jobs
const counters = {
total_jobs: jobs.length,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
active_jobs: 0,
}
const grouped = new Map()
const catalog = [
['worker.download_media', 'worker', '远程媒体下载', '抓取远程图片 / PDF 到媒体库,并回写媒体元数据。', 'media'],
['worker.notification_delivery', 'worker', '通知投递', '执行订阅通知、测试通知与 digest 投递。', 'notifications'],
['task.retry_deliveries', 'task', '重试待投递通知', '扫描 retry_pending 的通知记录并重新入队。', 'maintenance'],
['task.send_weekly_digest', 'task', '发送周报', '根据近期内容生成周报,并为活跃订阅目标入队。', 'digests'],
['task.send_monthly_digest', 'task', '发送月报', '根据近期内容生成月报,并为活跃订阅目标入队。', 'digests'],
].map(([worker_name, job_kind, label, description, queue_name]) => ({
worker_name,
job_kind,
label,
description,
queue_name,
supports_cancel: true,
supports_retry: true,
}))
for (const job of jobs) {
if (Object.hasOwn(counters, job.status)) {
counters[job.status] += 1
}
const existing =
grouped.get(job.worker_name) ||
{
worker_name: job.worker_name,
job_kind: job.job_kind,
label: catalog.find((item) => item.worker_name === job.worker_name)?.label || job.worker_name,
queued: 0,
running: 0,
succeeded: 0,
failed: 0,
cancelled: 0,
last_job_at: null,
}
if (Object.hasOwn(existing, job.status)) {
existing[job.status] += 1
}
existing.last_job_at ||= job.created_at
grouped.set(job.worker_name, existing)
}
counters.active_jobs = counters.queued + counters.running
return {
...counters,
worker_stats: Array.from(grouped.values()),
catalog,
}
}
function enqueueNotificationDeliveryJob(delivery, options = {}) {
return createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.notification_delivery',
display_name: `${delivery.event_type}${delivery.target}`,
status: options.status || 'succeeded',
queue_name: 'notifications',
payload: {
delivery_id: delivery.id,
job_id: null,
},
result: options.status === 'failed' ? null : { delivery_id: delivery.id },
error_text: options.status === 'failed' ? options.error_text || 'mock delivery failed' : null,
tags: ['notifications', 'delivery'],
related_entity_type: 'notification_delivery',
related_entity_id: delivery.id,
parent_job_id: options.parent_job_id ?? null,
})
}
function sanitizeFilename(value, fallback = 'upload.bin') { function sanitizeFilename(value, fallback = 'upload.bin') {
const normalized = String(value || '').split(/[\\/]/).pop() || fallback const normalized = String(value || '').split(/[\\/]/).pop() || fallback
return normalized.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || fallback return normalized.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || fallback
@@ -1249,6 +1407,38 @@ function makeMediaRecordFromUpload(key, file) {
} }
} }
function queueDownloadWorkerJob(payload, mediaRecord) {
return createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.download_media',
display_name: normalizeText(payload.title) || `download ${normalizeText(payload.source_url)}`,
status: 'succeeded',
queue_name: 'media',
payload: {
source_url: payload.source_url,
prefix: payload.prefix || null,
title: payload.title || null,
alt_text: payload.alt_text || null,
caption: payload.caption || null,
tags: Array.isArray(payload.tags) ? payload.tags : [],
notes: payload.notes || null,
job_id: null,
},
result: mediaRecord
? {
key: mediaRecord.key,
url: mediaRecord.url,
size_bytes: mediaRecord.size_bytes,
source_url: normalizeText(payload.source_url),
content_type: mediaRecord.content_type,
}
: null,
tags: ['media', 'download'],
related_entity_type: 'media_download',
related_entity_id: normalizeText(payload.source_url),
})
}
function upsertPostFromPayload(current, payload) { function upsertPostFromPayload(current, payload) {
const next = current ? { ...current } : {} const next = current ? { ...current } : {}
const title = normalizeText(payload.title) || normalizeText(current?.title) || '未命名文章' const title = normalizeText(payload.title) || normalizeText(current?.title) || '未命名文章'
@@ -1549,6 +1739,7 @@ function latestDebugState() {
filters: clone(item.filters), filters: clone(item.filters),
})), })),
deliveries: state.deliveries.map((item) => clone(item)), deliveries: state.deliveries.map((item) => clone(item)),
worker_jobs: state.worker_jobs.map((item) => clone(item)),
media: state.media.map((item) => ({ media: state.media.map((item) => ({
key: item.key, key: item.key,
title: item.title, title: item.title,
@@ -1765,7 +1956,10 @@ const server = createServer(async (req, res) => {
} }
if (pathname === '/api/reviews' && req.method === 'GET') { if (pathname === '/api/reviews' && req.method === 'GET') {
json(res, 200, state.reviews.map((item) => clone(item))) const items = isAuthenticated(req)
? state.reviews
: state.reviews.filter((item) => isPublicReviewVisible(item))
json(res, 200, items.map((item) => clone(item)))
return return
} }
@@ -1801,7 +1995,7 @@ const server = createServer(async (req, res) => {
if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'GET') { if (pathname.match(/^\/api\/reviews\/\d+$/) && req.method === 'GET') {
const id = Number(pathname.split('/').pop()) const id = Number(pathname.split('/').pop())
const review = state.reviews.find((item) => item.id === id) const review = state.reviews.find((item) => item.id === id)
if (!review) { if (!review || (!isAuthenticated(req) && !isPublicReviewVisible(review))) {
notFound(res, '评测不存在。') notFound(res, '评测不存在。')
return return
} }
@@ -2836,10 +3030,11 @@ const server = createServer(async (req, res) => {
status: 'queued', status: 'queued',
}) })
state.deliveries.unshift(delivery) state.deliveries.unshift(delivery)
const job = enqueueNotificationDeliveryJob(delivery)
record.last_delivery_status = delivery.status record.last_delivery_status = delivery.status
record.last_notified_at = iso(-1) record.last_notified_at = iso(-1)
addAuditLog('subscription.test', 'subscription', record.target, record.id) addAuditLog('subscription.test', 'subscription', record.target, record.id)
json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id }) json(res, 200, { queued: true, id: record.id, delivery_id: delivery.id, job_id: job.id })
return return
} }
@@ -2855,14 +3050,14 @@ const server = createServer(async (req, res) => {
const period = normalizeText(payload.period) || 'weekly' const period = normalizeText(payload.period) || 'weekly'
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active') const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
activeSubscriptions.forEach((subscription) => { activeSubscriptions.forEach((subscription) => {
state.deliveries.unshift( const delivery = createNotificationDelivery({
createNotificationDelivery({
subscription, subscription,
eventType: `digest.${period}`, eventType: `digest.${period}`,
status: 'queued', status: 'queued',
payload: { period }, payload: { period },
}), })
) state.deliveries.unshift(delivery)
enqueueNotificationDeliveryJob(delivery)
subscription.last_delivery_status = 'queued' subscription.last_delivery_status = 'queued'
subscription.last_notified_at = iso(-1) subscription.last_notified_at = iso(-1)
}) })
@@ -2876,6 +3071,205 @@ const server = createServer(async (req, res) => {
return return
} }
if (pathname === '/api/admin/workers/overview' && req.method === 'GET') {
json(res, 200, buildWorkerOverview())
return
}
if (pathname === '/api/admin/workers/jobs' && req.method === 'GET') {
const status = normalizeText(searchParams.get('status'))
const jobKind = normalizeText(searchParams.get('job_kind'))
const workerName = normalizeText(searchParams.get('worker_name'))
const keyword = normalizeText(searchParams.get('search'))
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
let items = [...state.worker_jobs]
if (status) {
items = items.filter((item) => item.status === status)
}
if (jobKind) {
items = items.filter((item) => item.job_kind === jobKind)
}
if (workerName) {
items = items.filter((item) => item.worker_name === workerName)
}
if (keyword) {
items = items.filter((item) =>
[item.worker_name, item.display_name, item.related_entity_type, item.related_entity_id]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword.toLowerCase())),
)
}
const total = items.length
if (limit > 0) {
items = items.slice(0, limit)
}
json(res, 200, { total, jobs: items.map((item) => normalizeWorkerJob(item)) })
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+$/) && req.method === 'GET') {
const id = Number(pathname.split('/').pop())
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
json(res, 200, normalizeWorkerJob(job))
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+\/cancel$/) && req.method === 'POST') {
const id = Number(pathname.split('/')[5])
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
job.cancel_requested = true
if (job.status === 'queued') {
job.status = 'cancelled'
job.finished_at = iso(-1)
job.error_text = 'job cancelled before start'
}
job.updated_at = iso(-1)
addAuditLog('worker.cancel', 'worker_job', job.worker_name, job.id)
json(res, 200, normalizeWorkerJob(job))
return
}
if (pathname.match(/^\/api\/admin\/workers\/jobs\/\d+\/retry$/) && req.method === 'POST') {
const id = Number(pathname.split('/')[5])
const job = state.worker_jobs.find((item) => item.id === id)
if (!job) {
notFound(res, 'worker job 不存在。')
return
}
let nextJob = null
if (job.worker_name === 'task.send_weekly_digest' || job.worker_name === 'task.send_monthly_digest') {
const period = job.worker_name === 'task.send_monthly_digest' ? 'monthly' : 'weekly'
nextJob = createWorkerJob({
job_kind: 'task',
worker_name: job.worker_name,
display_name: period === 'monthly' ? '发送月报' : '发送周报',
status: 'succeeded',
queue_name: 'digests',
payload: { period },
result: {
period,
post_count: state.posts.filter((item) => item.status === 'published').length,
queued: state.subscriptions.filter((item) => item.status === 'active').length,
skipped: state.subscriptions.filter((item) => item.status !== 'active').length,
},
tags: ['digest', period],
related_entity_type: 'subscription_digest',
related_entity_id: period,
parent_job_id: job.id,
trigger_mode: 'retry',
})
} else if (job.worker_name === 'worker.download_media') {
const payload = job.payload || {}
nextJob = createWorkerJob({
job_kind: 'worker',
worker_name: 'worker.download_media',
display_name: job.display_name,
status: 'succeeded',
queue_name: 'media',
payload,
result: job.result,
tags: ['media', 'download'],
related_entity_type: job.related_entity_type,
related_entity_id: job.related_entity_id,
parent_job_id: job.id,
trigger_mode: 'retry',
})
} else {
nextJob = createWorkerJob({
job_kind: job.job_kind,
worker_name: job.worker_name,
display_name: job.display_name,
status: 'succeeded',
queue_name: job.queue_name,
payload: clone(job.payload),
result: clone(job.result),
tags: Array.isArray(job.tags) ? job.tags : [],
related_entity_type: job.related_entity_type,
related_entity_id: job.related_entity_id,
parent_job_id: job.id,
trigger_mode: 'retry',
})
}
addAuditLog('worker.retry', 'worker_job', nextJob.worker_name, nextJob.id, { source_job_id: job.id })
json(res, 200, { queued: true, job: normalizeWorkerJob(nextJob) })
return
}
if (pathname === '/api/admin/workers/tasks/retry-deliveries' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const limit = Number.parseInt(String(payload.limit || '80'), 10) || 80
const retryable = state.deliveries
.filter((item) => item.status === 'retry_pending')
.slice(0, limit)
retryable.forEach((delivery) => {
delivery.status = 'queued'
delivery.updated_at = iso(-1)
delivery.next_retry_at = null
enqueueNotificationDeliveryJob(delivery)
})
const job = createWorkerJob({
job_kind: 'task',
worker_name: 'task.retry_deliveries',
display_name: '重试待投递通知',
status: 'succeeded',
queue_name: 'maintenance',
payload: { limit },
result: { limit, queued: retryable.length },
tags: ['maintenance', 'retry'],
related_entity_type: 'notification_delivery',
related_entity_id: null,
})
addAuditLog('worker.task.retry_deliveries', 'worker_job', job.worker_name, job.id, { limit })
json(res, 200, { queued: true, job: normalizeWorkerJob(job) })
return
}
if (pathname === '/api/admin/workers/tasks/digest' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const period = normalizeText(payload.period) === 'monthly' ? 'monthly' : 'weekly'
const activeSubscriptions = state.subscriptions.filter((item) => item.status === 'active')
activeSubscriptions.forEach((subscription) => {
const delivery = createNotificationDelivery({
subscription,
eventType: `digest.${period}`,
status: 'queued',
payload: { period },
})
state.deliveries.unshift(delivery)
enqueueNotificationDeliveryJob(delivery)
})
const job = createWorkerJob({
job_kind: 'task',
worker_name: period === 'monthly' ? 'task.send_monthly_digest' : 'task.send_weekly_digest',
display_name: period === 'monthly' ? '发送月报' : '发送周报',
status: 'succeeded',
queue_name: 'digests',
payload: { period },
result: {
period,
post_count: state.posts.filter((item) => item.status === 'published').length,
queued: activeSubscriptions.length,
skipped: state.subscriptions.length - activeSubscriptions.length,
},
tags: ['digest', period],
related_entity_type: 'subscription_digest',
related_entity_id: period,
})
addAuditLog('worker.task.digest', 'worker_job', job.worker_name, job.id, { period })
json(res, 200, { queued: true, job: normalizeWorkerJob(job) })
return
}
if (pathname === '/api/admin/storage/media' && req.method === 'GET') { if (pathname === '/api/admin/storage/media' && req.method === 'GET') {
const prefix = normalizeText(searchParams.get('prefix')) const prefix = normalizeText(searchParams.get('prefix'))
const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0 const limit = Number.parseInt(searchParams.get('limit') || '0', 10) || 0
@@ -2970,6 +3364,42 @@ const server = createServer(async (req, res) => {
return return
} }
if (pathname === '/api/admin/storage/media/download' && req.method === 'POST') {
const { json: payload } = await parseRequest(req)
const sourceUrl = normalizeText(payload.source_url)
if (!sourceUrl) {
badRequest(res, '缺少远程素材地址。')
return
}
const prefix = normalizeText(payload.prefix) || 'uploads/'
const fileName = sanitizeFilename(sourceUrl.split('/').pop(), 'remote-asset.svg')
const key = `${prefix}${Date.now()}-${fileName}`
const title = normalizeText(payload.title) || sanitizeFilename(fileName, 'remote-asset')
const record = {
key,
url: `${MOCK_ORIGIN}/media/${encodeURIComponent(key)}`,
size_bytes: 2048,
last_modified: iso(-1),
title,
alt_text: normalizeText(payload.alt_text) || null,
caption: normalizeText(payload.caption) || null,
tags: Array.isArray(payload.tags) ? payload.tags.filter(Boolean) : [],
notes: normalizeText(payload.notes) || `downloaded from ${sourceUrl}`,
body: `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><rect width="1200" height="675" fill="#111827"/><text x="72" y="200" fill="#f8fafc" font-family="monospace" font-size="40">${title}</text><text x="72" y="280" fill="#94a3b8" font-family="monospace" font-size="24">${sourceUrl}</text></svg>`,
content_type: CONTENT_TYPES.svg,
}
state.media.unshift(record)
const job = queueDownloadWorkerJob(payload, record)
addAuditLog('media.download', 'media', sourceUrl, job.id, { key })
json(res, 200, {
queued: true,
job_id: job.id,
status: job.status,
})
return
}
if (pathname === '/api/admin/storage/media/metadata' && req.method === 'PATCH') { if (pathname === '/api/admin/storage/media/metadata' && req.method === 'PATCH') {
const { json: payload } = await parseRequest(req) const { json: payload } = await parseRequest(req)
const key = normalizeText(payload.key) const key = normalizeText(payload.key)

View File

@@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test' import { expect, test, type Page } from '@playwright/test'
import { getDebugState, loginAdmin, resetMockState } from './helpers' import { getDebugState, loginAdmin, MOCK_BASE_URL, resetMockState } from './helpers'
test.beforeEach(async ({ request }) => { test.beforeEach(async ({ request }) => {
await resetMockState(request) await resetMockState(request)
@@ -39,6 +39,7 @@ test('后台登录、导航与关键模块页面可加载', async ({ page }) =>
{ label: '评测', url: /\/reviews$/, text: '《漫长的季节》' }, { label: '评测', url: /\/reviews$/, text: '《漫长的季节》' },
{ label: '媒体库', url: /\/media$/, text: '漫长的季节封面' }, { label: '媒体库', url: /\/media$/, text: '漫长的季节封面' },
{ label: '订阅', url: /\/subscriptions$/, text: 'watcher@example.com' }, { label: '订阅', url: /\/subscriptions$/, text: 'watcher@example.com' },
{ label: 'Workers', url: /\/workers$/, text: '异步 Worker 控制台' },
{ label: '审计', url: /\/audit$/, text: 'playwright-smoke' }, { label: '审计', url: /\/audit$/, text: 'playwright-smoke' },
{ label: '设置', url: /\/settings$/, text: 'InitCool' }, { label: '设置', url: /\/settings$/, text: 'InitCool' },
] ]
@@ -50,6 +51,17 @@ test('后台登录、导航与关键模块页面可加载', async ({ page }) =>
} }
}) })
test('后台 dashboard worker 健康卡片可跳转到带筛选的 workers', async ({ page }) => {
await loginAdmin(page)
await expect(page.locator('main')).toContainText('Worker 活动')
await page.getByTestId('dashboard-worker-card-failed').click()
await expect(page).toHaveURL(/\/workers\?status=failed$/)
await expect(page.locator('main')).toContainText('异步 Worker 控制台')
await expect(page.locator('main')).toContainText('failed')
})
test('后台可以审核评论和友链,并更新站点设置', async ({ page }) => { test('后台可以审核评论和友链,并更新站点设置', async ({ page }) => {
await loginAdmin(page) await loginAdmin(page)
@@ -136,8 +148,21 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
await expect(row).toContainText('Deep Regression Updated') await expect(row).toContainText('Deep Regression Updated')
await row.getByTestId(/subscription-test-/).click() await row.getByTestId(/subscription-test-/).click()
await expect(page.getByTestId('subscriptions-last-job')).toBeVisible()
await page.getByTestId('subscriptions-last-job').click()
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
await expect(page.locator('main')).toContainText('subscription.test')
await page.getByRole('link', { name: '订阅' }).click()
await page.getByTestId('subscriptions-send-weekly').click() await page.getByTestId('subscriptions-send-weekly').click()
await expect(page.getByTestId('subscriptions-last-job')).toBeVisible()
await page.getByTestId('subscriptions-send-monthly').click() await page.getByTestId('subscriptions-send-monthly').click()
await expect(page.getByTestId(/^subscription-delivery-job-/).first()).toBeVisible()
await page.getByTestId(/^subscription-delivery-job-/).first().click()
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
await expect(page.locator('main')).toContainText('worker.notification_delivery')
await page.getByRole('link', { name: '订阅' }).click()
let state = await getDebugState(request) let state = await getDebugState(request)
expect( expect(
@@ -146,6 +171,9 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
).toBeTruthy() ).toBeTruthy()
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.weekly')).toBeTruthy() expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.weekly')).toBeTruthy()
expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.monthly')).toBeTruthy() expect(state.deliveries.some((item: { event_type: string }) => item.event_type === 'digest.monthly')).toBeTruthy()
expect(
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'worker.notification_delivery'),
).toBeTruthy()
await row.getByTestId(/subscription-delete-/).click() await row.getByTestId(/subscription-delete-/).click()
await expect(row).toHaveCount(0) await expect(row).toHaveCount(0)
@@ -156,6 +184,33 @@ test('后台可完成订阅 CRUD、测试投递与 digest 入队', async ({ page
).toBeFalsy() ).toBeFalsy()
}) })
test('后台可查看 worker 控制台并执行 digest / retry / job 重跑', async ({ page, request }) => {
await loginAdmin(page)
await page.getByRole('link', { name: 'Workers' }).click()
await page.getByTestId('workers-run-weekly').click()
await expect(page.locator('main')).toContainText('task.send_weekly_digest')
await page.getByTestId('workers-retry-job').click()
let state = await getDebugState(request)
expect(
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'task.send_weekly_digest'),
).toBeTruthy()
expect(
state.worker_jobs.some(
(item: { worker_name: string; parent_job_id: number | null }) =>
item.worker_name === 'task.send_weekly_digest' && item.parent_job_id !== null,
),
).toBeTruthy()
await page.getByTestId('workers-run-retry').click()
state = await getDebugState(request)
expect(
state.worker_jobs.some((item: { worker_name: string }) => item.worker_name === 'task.retry_deliveries'),
).toBeTruthy()
})
test('后台可完成文章创建、保存、版本恢复与删除', async ({ page, request }) => { test('后台可完成文章创建、保存、版本恢复与删除', async ({ page, request }) => {
await loginAdmin(page) await loginAdmin(page)
await page.getByRole('link', { name: '文章' }).click() await page.getByRole('link', { name: '文章' }).click()
@@ -209,6 +264,15 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
await loginAdmin(page) await loginAdmin(page)
await page.getByRole('link', { name: '媒体库' }).click() await page.getByRole('link', { name: '媒体库' }).click()
await page.getByTestId('media-remote-url').fill(`${MOCK_BASE_URL}/media-files/remote-playwright.svg`)
await page.getByTestId('media-remote-title').fill('Remote Playwright Cover')
await page.getByTestId('media-remote-download').click()
await expect(page.getByTestId('media-last-remote-job')).toBeVisible()
await page.getByTestId('media-last-remote-job').click()
await expect(page).toHaveURL(/\/workers\?job=\d+$/)
await expect(page.locator('main')).toContainText('worker.download_media')
await page.getByRole('link', { name: '媒体库' }).click()
await page.getByTestId('media-upload-input').setInputFiles([ await page.getByTestId('media-upload-input').setInputFiles([
buildSvgPayload('deep-regression-cover.svg', 'deep-upload'), buildSvgPayload('deep-regression-cover.svg', 'deep-upload'),
]) ])
@@ -222,6 +286,13 @@ test('后台可完成媒体库上传/元数据/替换/删除,并执行设置
await page.getByTestId('media-save-metadata').click() await page.getByTestId('media-save-metadata').click()
let state = await getDebugState(request) let state = await getDebugState(request)
expect(
state.media.some(
(item: { title: string; key: string }) =>
item.title === 'Remote Playwright Cover' &&
String(item.key || '').includes('post-covers/'),
),
).toBeTruthy()
expect( expect(
state.media.some( state.media.some(
(item: { title: string; alt_text: string; tags: string[] }) => (item: { title: string; alt_text: string; tags: string[] }) =>

View File

@@ -24,6 +24,36 @@ test('首页过滤、热门区和文章详情链路可用', async ({ page }) =>
await expect(page).toHaveURL(/\/articles\/playwright-regression-workflow$/) await expect(page).toHaveURL(/\/articles\/playwright-regression-workflow$/)
await expect(page.getByRole('heading', { name: 'Playwright 回归工作流设计' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Playwright 回归工作流设计' })).toBeVisible()
await expect(page.locator('.paragraph-comment-marker').first()).toBeVisible() await expect(page.locator('.paragraph-comment-marker').first()).toBeVisible()
await page.goto('/categories/frontend-engineering')
await expect(page.getByRole('heading', { name: '前端工程' })).toBeVisible()
await expect(page.getByText('Astro 终端博客信息架构实战')).toBeVisible()
await page.goto('/tags/playwright')
await expect(page.getByRole('heading', { name: 'Playwright', exact: true })).toBeVisible()
await expect(page.getByText('Playwright 回归工作流设计')).toBeVisible()
await page.goto('/reviews')
await expect(page.getByText('《宇宙探索编辑部》')).toHaveCount(0)
await page.goto('/reviews/4')
await expect(page.getByRole('heading', { name: '评价不存在' })).toBeVisible()
await page.goto('/reviews/1')
await page.getByRole('link', { name: '#年度最佳' }).click()
await expect(page).toHaveURL(/\/reviews\?tag=%E5%B9%B4%E5%BA%A6%E6%9C%80%E4%BD%B3$/)
await expect(page.getByText('《漫长的季节》')).toBeVisible()
await page.goto('/reviews/1')
await page.getByRole('link', { name: '动画' }).click()
await expect(page).toHaveURL(/\/reviews\?type=anime$/)
await expect(page.locator('#reviews-subtitle')).toContainText('动画')
await expect(page.getByText('《漫长的季节》')).toBeVisible()
await page.goto('/reviews/1')
await page.getByRole('link', { name: '已完成' }).click()
await expect(page).toHaveURL(/\/reviews\?status=completed$/)
await expect(page.locator('#reviews-subtitle')).toContainText('已完成')
await expect(page.getByText('《漫长的季节》')).toBeVisible()
}) })
test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => { test('文章评论、搜索和 AI 问答链路可用', async ({ page, request }) => {
@@ -64,16 +94,27 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy() expect(friendState.friend_links.some((item: { site_name: string }) => item.site_name === 'Playwright Friend')).toBeTruthy()
await page.goto('/') await page.goto('/')
await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户')
await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com')
await page.locator('[data-subscribe-form] button[type="submit"]').click()
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
await page.locator('[data-subscription-popup-open]').click() await page.locator('[data-subscription-popup-open]').click()
await page.locator('[data-subscription-popup-form] input[name="displayName"]').fill('弹窗订阅用户')
await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com') await page.locator('[data-subscription-popup-email]').fill('playwright-subscriber@example.com')
await page.locator('[data-subscription-popup-form] button[type="submit"]').click() await page.locator('[data-subscription-popup-form] button[type="submit"]').click()
await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅') await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅')
const subscriptionState = await getDebugState(request) const subscriptionState = await getDebugState(request)
const inlineRecord = subscriptionState.subscriptions.find(
(item: { target: string; display_name: string }) => item.target === 'inline-subscriber@example.com',
)
expect(inlineRecord?.display_name).toBe('首页订阅用户')
const latest = subscriptionState.subscriptions.find( const latest = subscriptionState.subscriptions.find(
(item: { target: string }) => item.target === 'playwright-subscriber@example.com', (item: { target: string; display_name: string }) => item.target === 'playwright-subscriber@example.com',
) )
expect(latest).toBeTruthy() expect(latest).toBeTruthy()
expect(latest.display_name).toBe('弹窗订阅用户')
await page.goto(`/subscriptions/confirm?token=${encodeURIComponent(latest.confirm_token)}`) await page.goto(`/subscriptions/confirm?token=${encodeURIComponent(latest.confirm_token)}`)
await expect(page.getByText('订阅已确认')).toBeVisible() await expect(page.getByText('订阅已确认')).toBeVisible()