diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index cb4e510c..c60e2084 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -6,9 +6,9 @@ import { GlobeIcon, InfoIcon, LockIcon, - MessageSquareIcon, - PinIcon, - ShieldIcon, + MessageSquareIcon, + PinIcon, + ShieldIcon, SparklesIcon, SquarePenIcon, StarIcon, @@ -54,11 +54,11 @@ import { useAgent, useCreateAgent, useDeleteAgent, - useSetAgentFeatured, - useSetAgentPrivate, - useToggleAgentFavorite, - useToggleAgentPin, - useUpdateAgent, + useSetAgentFeatured, + useSetAgentPrivate, + useToggleAgentFavorite, + useToggleAgentPin, + useUpdateAgent, } from "@/core/agents"; import { getAgent } from "@/core/agents/api"; import type { Agent } from "@/core/agents"; @@ -70,6 +70,9 @@ import { cn } from "@/lib/utils"; interface AgentCardProps { agent: Agent; + /** "card" (default) renders the full gallery card; "list" renders a compact + * single-row layout for the list view. All dialogs/actions are shared. */ + layout?: "card" | "list"; } interface BadgeItem { @@ -181,16 +184,17 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) { ); } -export function AgentCard({ agent }: AgentCardProps) { +export function AgentCard({ agent, layout = "card" }: AgentCardProps) { + const isList = layout === "list"; const { t } = useI18n(); const router = useRouter(); const deleteAgent = useDeleteAgent(); const updateAgent = useUpdateAgent(); const createAgent = useCreateAgent(); const setFeatured = useSetAgentFeatured(); - const setPrivate = useSetAgentPrivate(); - const toggleFavorite = useToggleAgentFavorite(); - const togglePin = useToggleAgentPin(); + const setPrivate = useSetAgentPrivate(); + const toggleFavorite = useToggleAgentFavorite(); + const togglePin = useToggleAgentPin(); const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); @@ -228,8 +232,8 @@ export function AgentCard({ agent }: AgentCardProps) { // Favoriting collects a square agent into "我的"; pointless for one you // already own, so only offer it on built-in / others' agents. const canFavorite = !isOwned; - const isFavorite = !!agent.is_favorite; - const isPinned = !!agent.is_pinned; + const isFavorite = !!agent.is_favorite; + const isPinned = !!agent.is_pinned; useEffect(() => { if (!editOpen) { @@ -383,7 +387,7 @@ export function AgentCard({ agent }: AgentCardProps) { } } - async function handleToggleFavorite() { + async function handleToggleFavorite() { const next = !isFavorite; try { await toggleFavorite.mutateAsync({ id: agent.id, favorite: next }); @@ -391,17 +395,17 @@ export function AgentCard({ agent }: AgentCardProps) { } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } - } - - async function handleTogglePin() { - const next = !isPinned; - try { - await togglePin.mutateAsync({ id: agent.id, pinned: next }); - toast.success(next ? "已置顶" : "已取消置顶"); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } + } + + async function handleTogglePin() { + const next = !isPinned; + try { + await togglePin.mutateAsync({ id: agent.id, pinned: next }); + toast.success(next ? "已置顶" : "已取消置顶"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } async function handleToggleFeatured() { // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 @@ -434,274 +438,328 @@ export function AgentCard({ agent }: AgentCardProps) { const enabledSkills = skills.filter((skill) => skill.enabled); - return ( + // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by + // both the card header and the compact list row. + const statusBadges = ( <> - - -
-
-
- -
-
- - {desensitize(agent.name)} - - {agent.model && ( - - {desensitize(agent.model)} - - )} -
+ {isBuiltin && ( + + 内置 + + )} + {isOwned && ( + + 我的 + + )} + {isFromOthers && ( + + 来自他人 + + )} + {isOwned && agent.published && !agent.is_private && ( + + + 已发布 + + )} + {agent.is_private && ( + + + 私密 + + )} + {agent.featured_order != null && ( + + + 首页推荐 + + )} + + ); + + const skillBadgeItems = [ + ...(agent.tool_groups ?? []).map((group) => ({ + key: `tg:${group}`, + node: ( + + {desensitize(group)} + + ), + })), + ...(agent.skills ?? []).map((skill) => ({ + key: `sk:${skill}`, + node: ( + { + e.stopPropagation(); + setDetailSkillName(skill); + }} + > + {desensitize(skill)} + + ), + })), + ]; + const hasSkillsOrTools = + (agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0; + + const showActionRow = + canEdit || canDelete || isOwned || isAdmin || canFavorite; + + // The icon action buttons, shared by both layouts. + const actionButtons = ( + <> + {canFavorite && ( + + )} + + {isAdmin && ( + + )} + {isAdmin && ( + + )} + {isOwned && ( + + )} + {(isOwned || isAdmin) && ( + + )} + + {canEdit && ( + + )} + {canDelete && ( + + )} + + ); + + const chatButton = ( + + ); + + const cardLayout = ( + + +
+
+
+
-
- {isBuiltin && ( - - 内置 - - )} - {isOwned && ( - - 我的 - - )} - {isFromOthers && ( - - 来自他人 - - )} - {isOwned && agent.published && !agent.is_private && ( - - - 已发布 - - )} - {agent.is_private && ( - - - 私密 - - )} - {agent.featured_order != null && ( - - - 首页推荐 +
+ + {desensitize(agent.name)} + + {agent.model && ( + + {desensitize(agent.model)} )}
- {agent.description && ( - - {desensitize(agent.description)} - - )} - - - {(agent.tags?.length ?? 0) > 0 && ( - - - +
+ {statusBadges} +
+
+ {agent.description && ( + + {desensitize(agent.description)} + )} + - {(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && ( - - ({ - key: `tg:${group}`, - node: ( - - {desensitize(group)} - - ), - })), - ...(agent.skills ?? []).map((skill) => ({ - key: `sk:${skill}`, - node: ( - { - e.stopPropagation(); - setDetailSkillName(skill); - }} - > - {desensitize(skill)} - - ), - })), - ]} - /> - + {(agent.tags?.length ?? 0) > 0 && ( + + + + )} + + {hasSkillsOrTools && ( + + + + )} + + + {showActionRow && ( +
+ {actionButtons} +
)} + {chatButton} +
+ + ); - - {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && ( -
- {canFavorite && ( - - )} - - {isAdmin && ( - - )} - {isAdmin && ( - - )} - {isOwned && ( - - )} - {(isOwned || isAdmin) && ( - - )} - - {canEdit && ( - - )} - {canDelete && ( - - )} -
- )} - -
- + const listLayout = ( +
+
+ +
+
+
+ + {desensitize(agent.name)} + +
{statusBadges}
+
+ {agent.description && ( +

+ {desensitize(agent.description)} +

+ )} +
+
+ {showActionRow && actionButtons} + {chatButton} +
+
+ ); + + return ( + <> + {isList ? listLayout : cardLayout} diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 9a7d94b8..3be26cef 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -11,10 +11,11 @@ import { Pagination } from "@/components/ui/pagination"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; +import { ViewModeToggle } from "@/components/workspace/view-mode-toggle"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; -import { usePrivateSquareStatus } from "@/core/system-settings/hooks"; +import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks"; import { AgentCard } from "./agent-card"; @@ -28,6 +29,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); + const [viewMode, setViewMode] = useViewMode("agent"); const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">( "mine", ); @@ -125,13 +127,20 @@ export function AgentGallery() { setPrivatePassword(null); }; - const renderGrid = (list: Agent[]) => ( -
- {list.map((agent) => ( - - ))} -
- ); + const renderGrid = (list: Agent[]) => + viewMode === "list" ? ( +
+ {list.map((agent) => ( + + ))} +
+ ) : ( +
+ {list.map((agent) => ( + + ))} +
+ ); /** Loading / empty / (grid + pagination) for one paginated tab. */ const renderListBody = (opts: { @@ -308,6 +317,7 @@ export function AgentGallery() { 共 {activeTotal} 个 + {activeTab === "private" && privatePassword && ( + +

+ 当前系统默认:{current === "card" ? "卡片" : "列表"} +

+
+ ))} +
+ + + )} + void }) { const [tagIds, setTagIds] = useState([]); const [sort, setSort] = useState("time_desc"); const [page, setPage] = useState(1); + const [viewMode, setViewMode] = useViewMode("skill"); const reconcileMutation = useReconcileCustomSkills(); // Name + tag filtering is resolved server-side via the /api/skills query. const { skills, isLoading, error } = useSkills({ search, tagIds, sort }); @@ -335,25 +338,28 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { tagIds={tagIds} onTagIdsChange={setTagIds} actions={ - +
+ + +
} /> {isLoading && ( @@ -364,13 +370,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { )} {!isLoading && !error && filteredSkills.length > 0 && ( -
+
{pagedSkills.map((skill) => ( ))}
@@ -427,11 +440,15 @@ export function SkillRow({ skill, currentUserId, isAdmin, + layout = "row", }: { skill: Skill; currentUserId: string | null; isAdmin: boolean; + /** "row" = full-width horizontal item (default); "card" = grid card cell. */ + layout?: "row" | "card"; }) { + const isCard = layout === "card"; const { t } = useI18n(); const setPublished = useSetSkillPublished(); const setPinned = useSetSkillPinned(); @@ -529,207 +546,239 @@ export function SkillRow({ } } + const badgeGroup = ( +
+ + {desensitize(skill.name)} + + {isBuiltin && ( + + {t.settings.skills.badgeBuiltin} + + )} + {isOwned && ( + + {t.settings.skills.badgeMine} + + )} + {isFromOthers && ( + + {t.settings.skills.badgeFromOthers} + + )} + {isOwned && skill.published && ( + + + {t.settings.skills.badgePublished} + + )} + {isCustom && !isBuiltin && skill.source === "agent" && ( + + {t.settings.skills.badgeSourceAgent} + + )} + {isCustom && !isBuiltin && skill.source === "upload" && ( + + {t.settings.skills.badgeSourceUpload} + + )} + {skill.featured && ( + + {t.settings.skills.badgeFeatured} + + )} + {skill.pinned && ( + + {t.settings.skills.badgePinned} + + )} +
+ ); + + const metaNodes = ( + <> + {skill.use_count !== undefined && ( +
+ {skill.use_count > 0 + ? t.settings.skills.useCountInline.replace( + "{count}", + String(skill.use_count), + ) + : t.settings.skills.useCountInlineZero} +
+ )} + {(skill.tags?.length ?? 0) > 0 && ( + + )} + + ); + + const actionButtons = ( + <> + {isAdmin && ( + + )} + {canManage && ( + + )} + {canManage && ( + + )} + {canTogglePublish && ( + + )} + + {canManage && ( + + )} + + ); + return ( <> - - setDetailOpen(true)} - > - -
- {desensitize(skill.name)} - {isBuiltin && ( - - {t.settings.skills.badgeBuiltin} - - )} - {isOwned && ( - - {t.settings.skills.badgeMine} - - )} - {isFromOthers && ( - - {t.settings.skills.badgeFromOthers} - - )} - {isOwned && skill.published && ( - - - {t.settings.skills.badgePublished} - - )} - {isCustom && !isBuiltin && skill.source === "agent" && ( - - {t.settings.skills.badgeSourceAgent} - - )} - {isCustom && !isBuiltin && skill.source === "upload" && ( - - {t.settings.skills.badgeSourceUpload} - - )} - {skill.featured && ( - - {t.settings.skills.badgeFeatured} - - )} - {skill.pinned && ( - - {t.settings.skills.badgePinned} - - )} -
-
- - {desensitize(skill.description ?? "")} - - {skill.use_count !== undefined && ( -
- {skill.use_count > 0 - ? t.settings.skills.useCountInline.replace( - "{count}", - String(skill.use_count), - ) - : t.settings.skills.useCountInlineZero} -
- )} - {(skill.tags?.length ?? 0) > 0 && ( - - )} -
- - {isAdmin && ( - - )} - {canManage && ( - - )} - {canManage && ( - - )} - {canTogglePublish && ( - - )} - - {canManage && ( - - )} - -
+ {badgeGroup} +

+ {desensitize(skill.description ?? "")} +

+ {metaNodes} +
+
+ {actionButtons} +
+
+ ) : ( + + setDetailOpen(true)} + > + {badgeGroup} + + {desensitize(skill.description ?? "")} + + {metaNodes} + + {actionButtons} + + )} diff --git a/frontend-web/src/components/workspace/view-mode-toggle.tsx b/frontend-web/src/components/workspace/view-mode-toggle.tsx new file mode 100644 index 00000000..552a61f5 --- /dev/null +++ b/frontend-web/src/components/workspace/view-mode-toggle.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { LayoutGridIcon, ListIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { ViewMode } from "@/core/system-settings/api"; +import { cn } from "@/lib/utils"; + +/** + * Compact card/list switch shown in the header of the skills and agents lists. + * Purely presentational — the parent owns the `value` (resolved from the user's + * local override or the admin default via `useViewMode`) and persists changes. + */ +export function ViewModeToggle({ + value, + onChange, + className, +}: { + value: ViewMode; + onChange: (mode: ViewMode) => void; + className?: string; +}) { + return ( +
+ + +
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 2c7c085f..eaf72024 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -4,9 +4,10 @@ import { BellIcon, BotIcon, BrainIcon, - CalendarClockIcon, - ChevronsUpDown, - GaugeIcon, + CalendarClockIcon, + ChevronsUpDown, + DatabaseZapIcon, + GaugeIcon, HistoryIcon, MessageSquareCodeIcon, MessageSquarePlusIcon, @@ -198,6 +199,16 @@ export function WorkspaceNavMenu() { 用户排行榜 )} + {isAdmin && ( + + navigate("/page/workspace/settings/checkpoint-migration") + } + > + + Checkpoint 迁移 + + )} {isAdmin && ( navigate("/page/workspace/admin")} diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts index f5db4fa1..cf6de232 100644 --- a/frontend-web/src/core/settings/local.ts +++ b/frontend-web/src/core/settings/local.ts @@ -55,6 +55,13 @@ export interface LocalSettings { * app sizes text/spacing in rem, the whole UI scales proportionally. */ fontScale: number; + /** + * Per-user override for the skill / agent list display mode (card vs list). + * `undefined` = follow the admin-configured system default. Set when the + * user flips the card/list toggle on the skills or agents page. + */ + skillViewMode?: "card" | "list"; + agentViewMode?: "card" | "list"; }; context: Omit< AgentThreadContext, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index d94e38a4..9b5f3271 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -90,13 +90,18 @@ export async function loadPrivateSquareStatus(): Promise { } export type ThemeMode = "light" | "dark"; +export type ViewMode = "card" | "list"; export interface AppearanceSettings { default_theme: ThemeMode; + default_skill_view: ViewMode; + default_agent_view: ViewMode; } export interface AppearanceUpdate { default_theme?: ThemeMode; + default_skill_view?: ViewMode; + default_agent_view?: ViewMode; } export async function loadAppearanceSettings(): Promise { diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 6cf4f5af..e5a85608 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useLocalSettings } from "@/core/settings"; + import { loadAppearanceSettings, loadCitationDisplaySettings, @@ -16,6 +18,7 @@ import { type LeaderboardSettingsUpdate, type PrivateSquareUpdate, type PromptPrefixUpdate, + type ViewMode, } from "./api"; const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const; @@ -86,6 +89,31 @@ export function useUpdateAppearanceSettings() { }); } +/** + * Effective card/list display mode for the skill or agent list. A user's own + * choice (persisted in local settings) wins; otherwise the admin-configured + * system default applies; otherwise a built-in fallback (skills → list, + * agents → card, matching the historical layout of each page). `setMode` + * persists the user's override locally. + */ +export function useViewMode( + kind: "skill" | "agent", +): [ViewMode, (mode: ViewMode) => void] { + const [settings, setSettings] = useLocalSettings(); + const { data: appearance } = useAppearanceSettings(); + const overrideKey = kind === "skill" ? "skillViewMode" : "agentViewMode"; + const userOverride = settings.display[overrideKey]; + const adminDefault = + kind === "skill" + ? appearance?.default_skill_view + : appearance?.default_agent_view; + const fallback: ViewMode = kind === "skill" ? "list" : "card"; + const mode: ViewMode = userOverride ?? adminDefault ?? fallback; + const setMode = (next: ViewMode) => + setSettings("display", { [overrideKey]: next }); + return [mode, setMode]; +} + export function useCitationDisplaySettings() { return useQuery({ queryKey: CITATION_DISPLAY_KEY, diff --git a/frontend-web/src/pages/CheckpointMigrationPage.tsx b/frontend-web/src/pages/CheckpointMigrationPage.tsx new file mode 100644 index 00000000..29acd84e --- /dev/null +++ b/frontend-web/src/pages/CheckpointMigrationPage.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +interface CheckpointMigrationSources { + source_paths: string[]; +} + +interface CheckpointMigrationTask { + task_id: string; + status: "queued" | "running" | "completed" | "failed"; + start_at: string; + end_at: string; + source_paths: string[]; + total_threads: number; + copied_threads: number; + skipped_threads: number; + failed_threads: number; + skipped_ranges: number; + message: string; + error?: string | null; + created_at: string; + updated_at: string; +} + +async function loadSources(): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`, + ); + if (!response.ok) throw new Error("加载 SQLite 来源失败"); + return (await response.json()) as CheckpointMigrationSources; +} + +async function startMigration(body: { + start_at: string; + end_at: string; +}): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const detail = await response.json().catch(() => ({ detail: "启动迁移失败" })); + throw new Error(detail.detail ?? "启动迁移失败"); + } + return (await response.json()) as CheckpointMigrationTask; +} + +async function loadTask(taskId: string): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`, + ); + if (!response.ok) throw new Error("加载迁移任务失败"); + return (await response.json()) as CheckpointMigrationTask; +} + +function toLocalInputValue(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function statusVariant(status?: string) { + if (status === "failed") return "destructive" as const; + if (status === "completed") return "default" as const; + if (status === "queued" || status === "running") return "secondary" as const; + return "outline" as const; +} + +export default function CheckpointMigrationPage() { + const now = useMemo(() => new Date(), []); + const weekAgo = useMemo( + () => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + [now], + ); + const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo)); + const [endAt, setEndAt] = useState(toLocalInputValue(now)); + const [taskId, setTaskId] = useState(null); + + const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({ + queryKey: ["checkpointMigration", "sources"], + queryFn: loadSources, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + const startMutation = useMutation({ mutationFn: startMigration }); + const { data: task } = useQuery({ + queryKey: ["checkpointMigration", "task", taskId], + queryFn: () => loadTask(taskId as string), + enabled: !!taskId, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === "queued" || status === "running" ? 2000 : false; + }, + refetchOnWindowFocus: false, + }); + + const running = + startMutation.isPending || + task?.status === "queued" || + task?.status === "running"; + + async function handleStart() { + const start = new Date(startAt); + const end = new Date(endAt); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + toast.error("请选择有效的开始和结束时间"); + return; + } + if (end <= start) { + toast.error("结束时间必须晚于开始时间"); + return; + } + try { + const created = await startMutation.mutateAsync({ + start_at: start.toISOString(), + end_at: end.toISOString(), + }); + setTaskId(created.task_id); + toast.success("迁移任务已启动"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "启动迁移失败"); + } + } + + return ( +
+
+

Checkpoint 迁移

+

+ 按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。 +

+
+ +
+
+
+ + SQLite 来源 +
+ +
+ {sourcesLoading ? ( +
+ + 加载中 +
+ ) : (sources?.source_paths.length ?? 0) > 0 ? ( +
+ {sources?.source_paths.map((path) => ( +
+ {path} +
+ ))} +
+ ) : ( +
+ 未配置 SQLite 来源路径 +
+ )} +
+ +
+
+ + +
+
+ +
+
+ + {task && ( +
+
+
任务状态
+ {task.status} +
+
+
+
总线程
+
+ {task.total_threads} +
+
+
+
已复制
+
+ {task.copied_threads} +
+
+
+
已跳过
+
+ {task.skipped_threads} +
+
+
+
跳过时间段
+
+ {task.skipped_ranges} +
+
+
+ {(task.message || task.error) && ( +
+ {task.error || task.message} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend-web/src/pages/ScheduledTasksPage.tsx b/frontend-web/src/pages/ScheduledTasksPage.tsx index e5d3e614..418f807e 100644 --- a/frontend-web/src/pages/ScheduledTasksPage.tsx +++ b/frontend-web/src/pages/ScheduledTasksPage.tsx @@ -751,7 +751,24 @@ export default function ScheduledTasksPage() {
- 执行记录 +
+ 执行记录 + {selectedTask && ( + + )} +