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
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:
@@ -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'
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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: '审计',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
529
admin/src/pages/workers-page.tsx
Normal file
529
admin/src/pages/workers-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
98
backend/migration/src/m20260402_000036_create_worker_jobs.rs
Normal file
98
backend/migration/src/m20260402_000036_create_worker_jobs.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
43
backend/src/models/_entities/worker_jobs.rs
Normal file
43
backend/src/models/_entities/worker_jobs.rs
Normal 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 {}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
835
backend/src/services/worker_jobs.rs
Normal file
835
backend/src/services/worker_jobs.rs
Normal 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))),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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))}
|
||||||
>
|
>
|
||||||
|
|||||||
173
frontend/src/pages/categories/[slug].astro
Normal file
173
frontend/src/pages/categories/[slug].astro
Normal 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>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
frontend/src/pages/tags/[slug].astro
Normal file
176
frontend/src/pages/tags/[slug].astro
Normal 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>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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[] }) =>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user