diff --git a/.gitea/workflows/ui-regression.yml b/.gitea/workflows/ui-regression.yml
index fb1917a..052b3a2 100644
--- a/.gitea/workflows/ui-regression.yml
+++ b/.gitea/workflows/ui-regression.yml
@@ -1,21 +1,6 @@
name: ui-regression
on:
- push:
- branches:
- - main
- - master
- paths:
- - admin/**
- - frontend/**
- - playwright-smoke/**
- - .gitea/workflows/ui-regression.yml
- pull_request:
- paths:
- - admin/**
- - frontend/**
- - playwright-smoke/**
- - .gitea/workflows/ui-regression.yml
workflow_dispatch:
jobs:
@@ -35,6 +20,11 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
+ cache: pnpm
+ cache-dependency-path: |
+ frontend/pnpm-lock.yaml
+ admin/pnpm-lock.yaml
+ playwright-smoke/pnpm-lock.yaml
- name: Install frontend deps
working-directory: frontend
@@ -48,7 +38,15 @@ jobs:
working-directory: playwright-smoke
run: pnpm install --frozen-lockfile
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('playwright-smoke/pnpm-lock.yaml') }}
+
- name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: playwright-smoke
run: pnpm exec playwright install --with-deps chromium
@@ -69,71 +67,22 @@ jobs:
VITE_FRONTEND_BASE_URL: http://127.0.0.1:4321
run: pnpm build
- - name: Prepare Playwright artifact folders
- run: |
- rm -rf playwright-smoke/.artifacts
- mkdir -p playwright-smoke/.artifacts/frontend
- mkdir -p playwright-smoke/.artifacts/admin
-
- name: Run frontend UI regression suite
id: ui_frontend
working-directory: playwright-smoke
continue-on-error: true
env:
- PLAYWRIGHT_USE_BUILT_APP: '1'
+ PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:frontend
- - name: Collect frontend Playwright artifacts
- if: always()
- run: |
- if [ -d playwright-smoke/playwright-report ]; then
- cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/frontend/playwright-report
- fi
- if [ -d playwright-smoke/test-results ]; then
- cp -R playwright-smoke/test-results playwright-smoke/.artifacts/frontend/test-results
- fi
- rm -rf playwright-smoke/playwright-report playwright-smoke/test-results
-
- name: Run admin UI regression suite
id: ui_admin
working-directory: playwright-smoke
continue-on-error: true
env:
- PLAYWRIGHT_USE_BUILT_APP: '1'
+ PLAYWRIGHT_USE_BUILT_APP: "1"
run: pnpm test:admin
- - name: Collect admin Playwright artifacts
- if: always()
- run: |
- if [ -d playwright-smoke/playwright-report ]; then
- cp -R playwright-smoke/playwright-report playwright-smoke/.artifacts/admin/playwright-report
- fi
- if [ -d playwright-smoke/test-results ]; then
- cp -R playwright-smoke/test-results playwright-smoke/.artifacts/admin/test-results
- fi
-
- - name: Summarize Playwright artifact paths
- if: always()
- shell: bash
- run: |
- set -euo pipefail
-
- echo "Gitea Actions 当前不支持 actions/upload-artifact@v4,改为直接输出产物目录:"
-
- for path in \
- "playwright-smoke/.artifacts/frontend/playwright-report" \
- "playwright-smoke/.artifacts/frontend/test-results" \
- "playwright-smoke/.artifacts/admin/playwright-report" \
- "playwright-smoke/.artifacts/admin/test-results"
- do
- if [ -d "${path}" ]; then
- echo "- ${path}"
- find "${path}" -maxdepth 2 -type f | sort | head -n 20
- else
- echo "- ${path} (missing)"
- fi
- done
-
- name: Mark workflow failed when any suite failed
if: steps.ui_frontend.outcome != 'success' || steps.ui_admin.outcome != 'success'
run: exit 1
diff --git a/README.md b/README.md
index 11eda4d..76217c0 100644
--- a/README.md
+++ b/README.md
@@ -89,9 +89,11 @@ pnpm dev
```powershell
cd backend
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
-cargo loco start 2>&1
+cargo loco start --server-and-worker 2>&1
```
+如果需要验证浏览器推送、异步通知、失败重试等 Redis 队列任务,本地不要只跑 `server`,要把 `worker` 一起带上;否则任务会停在 `queued`。
+
### Docker(生产部署,使用 Gitea Package 镜像)
补充部署分层与反代说明见:
diff --git a/admin/src/lib/worker-progress.ts b/admin/src/lib/worker-progress.ts
index f4c8567..7e9161a 100644
--- a/admin/src/lib/worker-progress.ts
+++ b/admin/src/lib/worker-progress.ts
@@ -114,3 +114,14 @@ export function formatWorkerProgress(
return progress.message ?? (details || null);
}
+
+export function getWorkerProgressPercent(
+ job: Pick,
+): number | null {
+ const progress = getWorkerProgress(job);
+ if (typeof progress?.percent !== "number") {
+ return null;
+ }
+
+ return Math.max(0, Math.min(100, Math.round(progress.percent)));
+}
diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx
index fd13070..cbdd843 100644
--- a/admin/src/pages/dashboard-page.tsx
+++ b/admin/src/pages/dashboard-page.tsx
@@ -10,15 +10,22 @@ import {
Star,
Tags,
Workflow,
-} from 'lucide-react'
-import { startTransition, useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { toast } from 'sonner'
+} from "lucide-react";
+import { startTransition, useCallback, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { toast } from "sonner";
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Skeleton } from '@/components/ui/skeleton'
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { formatDateTime } from "@/lib/admin-format";
import {
Table,
TableBody,
@@ -26,9 +33,9 @@ import {
TableHead,
TableHeader,
TableRow,
-} from '@/components/ui/table'
-import { adminApi, ApiError } from '@/lib/api'
-import { buildFrontendUrl } from '@/lib/frontend-url'
+} from "@/components/ui/table";
+import { adminApi, ApiError } from "@/lib/api";
+import { buildFrontendUrl } from "@/lib/frontend-url";
import {
formatCommentScope,
formatPostStatus,
@@ -37,8 +44,12 @@ import {
formatPostVisibility,
formatReviewStatus,
formatReviewType,
-} from '@/lib/admin-format'
-import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types'
+} from "@/lib/admin-format";
+import type {
+ AdminAnalyticsResponse,
+ AdminDashboardResponse,
+ WorkerOverview,
+} from "@/lib/types";
function StatCard({
label,
@@ -46,17 +57,21 @@ function StatCard({
note,
icon: Icon,
}: {
- label: string
- value: number
- note: string
- icon: typeof Rss
+ label: string;
+ value: number;
+ note: string;
+ icon: typeof Rss;
}) {
return (
-
{label}
-
{value}
+
+ {label}
+
+
+ {value}
+
{note}
@@ -64,75 +79,81 @@ function StatCard({
- )
+ );
}
function formatAiSourceLabel(value: string) {
switch (value) {
- case 'chatgpt-search':
- return 'ChatGPT Search'
- case 'perplexity':
- return 'Perplexity'
- case 'copilot-bing':
- return 'Copilot / Bing'
- case 'gemini':
- return 'Gemini'
- case 'claude':
- return 'Claude'
- case 'google':
- return 'Google'
- case 'duckduckgo':
- return 'DuckDuckGo'
- case 'kagi':
- return 'Kagi'
- case 'direct':
- return 'Direct'
+ case "chatgpt-search":
+ return "ChatGPT Search";
+ case "perplexity":
+ return "Perplexity";
+ case "copilot-bing":
+ return "Copilot / Bing";
+ case "gemini":
+ return "Gemini";
+ case "claude":
+ return "Claude";
+ case "google":
+ return "Google";
+ case "duckduckgo":
+ return "DuckDuckGo";
+ case "kagi":
+ return "Kagi";
+ case "direct":
+ return "Direct";
default:
- return value
+ return value;
}
}
export function DashboardPage() {
- const [data, setData] = useState(null)
- const [workerOverview, setWorkerOverview] = useState(null)
- const [analytics, setAnalytics] = useState(null)
- const [loading, setLoading] = useState(true)
- const [refreshing, setRefreshing] = useState(false)
+ const [data, setData] = useState(null);
+ const [workerOverview, setWorkerOverview] = useState(
+ null,
+ );
+ const [analytics, setAnalytics] = useState(
+ null,
+ );
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const loadDashboard = useCallback(async (showToast = false) => {
try {
if (showToast) {
- setRefreshing(true)
+ setRefreshing(true);
}
const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([
adminApi.dashboard(),
adminApi.getWorkersOverview(),
adminApi.analytics(),
- ])
+ ]);
startTransition(() => {
- setData(next)
- setWorkerOverview(nextWorkerOverview)
- setAnalytics(nextAnalytics)
- })
+ setData(next);
+ setWorkerOverview(nextWorkerOverview);
+ setAnalytics(nextAnalytics);
+ });
if (showToast) {
- toast.success('仪表盘已刷新。')
+ toast.success("仪表盘已刷新。");
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
- return
+ return;
}
- toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
+ toast.error(
+ error instanceof ApiError ? error.message : "无法加载仪表盘。",
+ );
} finally {
- setLoading(false)
- setRefreshing(false)
+ setLoading(false);
+ setRefreshing(false);
}
- }, [])
+ }, []);
useEffect(() => {
- void loadDashboard(false)
- }, [loadDashboard])
+ void loadDashboard(false);
+ }, [loadDashboard]);
if (loading || !data || !workerOverview || !analytics) {
return (
@@ -147,24 +168,24 @@ export function DashboardPage() {
- )
+ );
}
const statCards = [
{
- label: '文章总数',
+ label: "文章总数",
value: data.stats.total_posts,
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
icon: Rss,
},
{
- label: '待审核评论',
+ label: "待审核评论",
value: data.stats.pending_comments,
- note: '等待审核处理',
+ note: "等待审核处理",
icon: MessageSquareWarning,
},
{
- label: '发布待办',
+ label: "发布待办",
value:
data.stats.draft_posts +
data.stats.scheduled_posts +
@@ -174,30 +195,32 @@ export function DashboardPage() {
icon: Clock3,
},
{
- label: '分类数量',
+ label: "分类数量",
value: data.stats.total_categories,
note: `当前共有 ${data.stats.total_tags} 个标签`,
icon: FolderTree,
},
{
- label: 'AI 分块',
+ label: "AI 分块",
value: data.stats.ai_chunks,
- note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
+ note: data.stats.ai_enabled ? "知识库已启用" : "AI 功能当前关闭",
icon: BrainCircuit,
},
{
- label: 'Worker 活动',
+ label: "Worker 活动",
value: workerOverview.active_jobs,
note: `失败 ${workerOverview.failed} / 运行 ${workerOverview.running}`,
icon: Workflow,
},
- ]
+ ];
const aiTrafficShare =
analytics.content_overview.page_views_last_7d > 0
- ? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100
- : 0
- const topAiSource = analytics.ai_referrers_last_7d[0]
- const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length
+ ? (analytics.ai_discovery_page_views_last_7d /
+ analytics.content_overview.page_views_last_7d) *
+ 100
+ : 0;
+ const topAiSource = analytics.ai_referrers_last_7d[0];
+ const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length;
return (
@@ -207,14 +230,15 @@ export function DashboardPage() {
运营总览
- 这里汇总了最重要的发布、审核和 AI 信号,让日常运营在一个独立后台里完成闭环。
+ 这里汇总了最重要的发布、审核和 AI
+ 信号,让日常运营在一个独立后台里完成闭环。
@@ -241,9 +265,7 @@ export function DashboardPage() {
最近文章
-
- 最近同步到前台的文章内容。
-
+ 最近同步到前台的文章内容。
{data.recent_posts.length} 条
@@ -265,9 +287,13 @@ export function DashboardPage() {
{post.title}
- {post.pinned ? 置顶 : null}
+ {post.pinned ? (
+ 置顶
+ ) : null}
-
{post.slug}
+
+ {post.slug}
+
@@ -275,12 +301,18 @@ export function DashboardPage() {
- {formatPostStatus(post.status)}
- {formatPostVisibility(post.visibility)}
+
+ {formatPostStatus(post.status)}
+
+
+ {formatPostVisibility(post.visibility)}
+
{post.category}
- {post.created_at}
+
+ {post.created_at}
+
))}
@@ -291,19 +323,19 @@ export function DashboardPage() {
站点状态
-
- 快速查看前台站点与 AI 索引状态。
-
+ 快速查看前台站点与 AI 索引状态。
{data.site.site_name}
-
{data.site.site_url}
+
+ {data.site.site_url}
+
-
- {data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
+
+ {data.site.ai_enabled ? "AI 已开启" : "AI 已关闭"}
@@ -314,7 +346,9 @@ export function DashboardPage() {
评测
- {data.stats.total_reviews}
+
+ {data.stats.total_reviews}
+
@@ -323,7 +357,9 @@ export function DashboardPage() {
友链
- {data.stats.total_links}
+
+ {data.stats.total_links}
+
@@ -335,25 +371,35 @@ export function DashboardPage() {
-
{data.stats.draft_posts}
+
+ {data.stats.draft_posts}
+
草稿
-
{data.stats.scheduled_posts}
+
+ {data.stats.scheduled_posts}
+
定时发布
-
{data.stats.offline_posts}
+
+ {data.stats.offline_posts}
+
手动下线
-
{data.stats.expired_posts}
+
+ {data.stats.expired_posts}
+
自动过期
私有 {data.stats.private_posts}
- 不公开 {data.stats.unlisted_posts}
+
+ 不公开 {data.stats.unlisted_posts}
+
@@ -362,7 +408,9 @@ export function DashboardPage() {
最近一次 AI 索引
- {data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
+ {data.site.ai_last_indexed_at
+ ? formatDateTime(data.site.ai_last_indexed_at)
+ : "站点还没有建立过索引。"}
@@ -372,9 +420,13 @@ export function DashboardPage() {
GEO / AI 来源概览
- {analytics.ai_discovery_page_views_last_7d}
+
+ {analytics.ai_discovery_page_views_last_7d}
+
- 最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。
+ 最近 7 天来自 ChatGPT
+ Search、Perplexity、Copilot/Bing、Gemini、Claude
+ 的页面访问。
@@ -384,23 +436,41 @@ export function DashboardPage() {
-
访问占比
-
{Math.round(aiTrafficShare)}%
-
基于近 7 天全部 page_view
-
-
-
最高来源
-
- {topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'}
+
+ 访问占比
+
+
+ {Math.round(aiTrafficShare)}%
- {topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'}
+ 基于近 7 天全部 page_view
-
已识别来源
-
{totalAiSourceBuckets}
-
当前已聚合的 AI 搜索渠道
+
+ 最高来源
+
+
+ {topAiSource
+ ? formatAiSourceLabel(topAiSource.referrer)
+ : "暂无"}
+
+
+ {topAiSource
+ ? `${topAiSource.count} 次访问`
+ : "等待来源数据"}
+
+
+
+
+ 已识别来源
+
+
+ {totalAiSourceBuckets}
+
+
+ 当前已聚合的 AI 搜索渠道
+
@@ -408,15 +478,24 @@ export function DashboardPage() {
{analytics.ai_referrers_last_7d.slice(0, 4).map((item) => {
const width = `${Math.max(
- (item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100,
+ (item.count /
+ Math.max(
+ analytics.ai_discovery_page_views_last_7d,
+ 1,
+ )) *
+ 100,
8,
- )}%`
+ )}%`;
return (
- {formatAiSourceLabel(item.referrer)}
- {item.count}
+
+ {formatAiSourceLabel(item.referrer)}
+
+
+ {item.count}
+
- )
+ );
})}
) : (
-
最近 7 天还没有识别到 AI 搜索来源流量。
+
+ 最近 7 天还没有识别到 AI 搜索来源流量。
+
)}
@@ -440,12 +521,17 @@ export function DashboardPage() {
Worker 健康
- 当前排队 {workerOverview.queued}、运行 {workerOverview.running}、失败 {workerOverview.failed}。
+ 当前排队 {workerOverview.queued}、运行{" "}
+ {workerOverview.running}、失败 {workerOverview.failed}。