feat: 完善公开任务预览和用户问答历史

This commit is contained in:
Administrator committed 2026-06-07 21:18:16 +08:00
1 parent e7c745a512
commit 0001f86826
5 files changed
+984 -109

No files matched your search

+7 -1
View File
@@ -15,7 +15,9 @@ import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicScheduledTaskPage, {
PublicScheduledTaskFullscreenPage,
} from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
@@ -78,6 +80,10 @@ export function App() {
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
element={<PublicScheduledTaskFullscreenPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface UserQaItem {
run_id: string;
thread_id: string;
thread_title: string;
assistant_id: string;
status: string;
model_name: string;
question: string;
answer: string;
total_tokens: number;
created_at: string;
updated_at: string;
}
export interface UserQaPageResponse {
user_id: string;
page: number;
page_size: number;
total: number;
items: UserQaItem[];
}
async function parse<T>(res: Response, what: string): Promise<T> {
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
}
return res.json() as Promise<T>;
}
export async function listMyQa(
params: { page?: number; pageSize?: number; q?: string } = {},
): Promise<UserQaPageResponse> {
const search = new URLSearchParams({
page: String(params.page ?? 1),
page_size: String(params.pageSize ?? 20),
});
const query = (params.q ?? "").trim();
if (query) search.set("q", query);
return parse(
await apiFetch(`${getBackendBaseURL()}/api/users/me/qa?${search}`),
"Failed to load my Q&A",
);
}
@@ -6,16 +6,18 @@ import {
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
FileText,
List,
Loader2,
Maximize2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -127,6 +129,10 @@ async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse>
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
}
async function fetchPublicRun(runId: string): Promise<PublicRun> {
return fetchJson<PublicRun>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`);
}
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
return fetchJson<PublicFileContent>(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
@@ -667,12 +673,61 @@ type ContentTab = {
fileName?: string;
};
type PublicFullscreenPreview =
| { mode: "html"; title: string; html: string }
| { mode: "markdown"; title: string; content: string }
| { mode: "text"; title: string; content: string }
| { mode: "file"; title: string; file: PublicFileContent };
function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreenPreview }) {
if (preview.mode === "html") {
return <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
}
if (preview.mode === "markdown") {
return (
<MarkdownContent
className="max-w-none"
content={preview.content || "无正文内容"}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
);
}
if (preview.mode === "file") {
const content = preview.file.content || "无文件内容";
if (isMarkdownFile(preview.file)) {
return (
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
);
}
return <pre className="whitespace-pre-wrap break-words text-sm">{content}</pre>;
}
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
}
function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string {
const params = new URLSearchParams();
params.set("tab", tab.kind);
if (tab.kind === "file" && tab.fileIndex != null) {
params.set("file", String(tab.fileIndex));
}
return `#/public/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/fullscreen?${params.toString()}`;
}
/**
* Right-pane controller for a single run: builds the tab list (body + MDs),
* fetches each tab's source text, and hosts the view/edit toggle.
*/
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
const queryClient = useQueryClient();
const [fullscreenPreview, setFullscreenPreview] = useState<PublicFullscreenPreview | null>(null);
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
const isHtmlRun =
@@ -837,9 +892,67 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
const fileIndex = activeTabMeta?.fileIndex;
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
const showEmptyHint = !activeSource && !fileIsLoading;
const fullscreenHref = activeTabMeta ? publicFullscreenHref(taskId, run.id, activeTabMeta) : "";
const fullscreenDisabled =
!activeTabMeta ||
(activeTabMeta.kind === "html" ? !htmlContent : activeTabMeta.kind === "file" ? fileIsLoading : false);
const handleOpenFullscreen = useCallback(() => {
if (!activeTabMeta) return;
if (activeTabMeta.kind === "html") {
if (!htmlContent) return;
setFullscreenPreview({
mode: "html",
title: run.result?.title || "HTML 预览",
html: htmlContent,
});
return;
}
if (activeTabMeta.kind === "body") {
setFullscreenPreview({
mode: run.result?.format === "markdown" || run.result?.format === "mixed" ? "markdown" : "text",
title: run.result?.title || "正文预览",
content: activeSource || "无正文内容",
});
return;
}
const sourceFile = markdownFiles.find((file) => file.index === activeTabMeta.fileIndex);
setFullscreenPreview({
mode: "file",
title: activeTabMeta.fileName || sourceFile?.name || "文件预览",
file: {
index: activeTabMeta.fileIndex ?? 0,
name: activeTabMeta.fileName || sourceFile?.name || "result.md",
mime_type: sourceFile?.mime_type ?? "text/markdown",
kind: sourceFile?.kind ?? "markdown",
size: sourceFile?.size ?? activeSource.length,
previewable: true,
content: activeSource,
},
});
}, [activeTabMeta, activeSource, htmlContent, markdownFiles, run.result?.format, run.result?.title]);
return (
<>
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
<DialogTitle className="truncate text-base">
{fullscreenPreview?.title ?? "预览"}
</DialogTitle>
</DialogHeader>
{fullscreenPreview && (
<div
className={cn(
"min-h-0 flex-1",
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
)}
>
<PublicFullscreenPreviewContent preview={fullscreenPreview} />
</div>
)}
</DialogContent>
</Dialog>
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
<div className="min-w-0 flex-1">
<Tooltip>
@@ -859,6 +972,30 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isEditing && activeTabMeta && (
<>
<Button
size="sm"
variant="outline"
onClick={handleOpenFullscreen}
disabled={fullscreenDisabled}
title="全屏查看"
>
<Maximize2 className="size-4" />
全屏
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open(fullscreenHref, "_blank", "noopener,noreferrer")}
disabled={fullscreenDisabled}
title="新页面打开全屏预览"
>
<ExternalLink className="size-4" />
新页面
</Button>
</>
)}
{isFileTab && fileIndex != null && (
<a
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
@@ -999,3 +1136,153 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</>
);
}
export function PublicScheduledTaskFullscreenPage() {
const { taskId, runId } = useParams<{ taskId: string; runId: string }>();
const [searchParams] = useSearchParams();
const requestedTab = searchParams.get("tab");
const tabKind: ContentTab["kind"] =
requestedTab === "body" || requestedTab === "file" || requestedTab === "html"
? requestedTab
: "html";
const fileIndexValue = Number(searchParams.get("file"));
const fileIndexValid = Number.isInteger(fileIndexValue) && fileIndexValue >= 0;
const { data: run, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", "run", runId],
queryFn: () => fetchPublicRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
retry: false,
});
const isHtmlRun =
run?.result?.format === "html" ||
(run?.result?.files ?? []).some(
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
);
const { data: htmlData, isLoading: htmlLoading } = useQuery({
queryKey: ["public-scheduled-task", "html", runId],
queryFn: () => fetchPublicRunHtml(runId!),
enabled: !!runId && tabKind === "html" && isHtmlRun && !run?.result?.html,
refetchOnWindowFocus: false,
retry: false,
});
const {
data: fileData,
isLoading: fileLoading,
isError: fileIsError,
error: fileError,
} = useQuery({
queryKey: ["public-scheduled-task", "file", runId, fileIndexValue],
queryFn: () => fetchPublicRunFile(runId!, fileIndexValue),
enabled: !!runId && tabKind === "file" && fileIndexValid,
refetchOnWindowFocus: false,
retry: false,
});
const htmlContent = run?.result?.html ?? htmlData?.html ?? null;
const backHref = taskId ? `#/public/scheduled-tasks/${encodeURIComponent(taskId)}` : "#/public/scheduled-tasks";
const pageTitle =
tabKind === "file"
? fileData?.name || "文件预览"
: tabKind === "body"
? run?.result?.title || "正文预览"
: htmlData?.title || run?.result?.title || "HTML 预览";
return (
<TooltipProvider delayDuration={150}>
<div className="bg-background flex h-screen min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Button asChild variant="ghost" size="sm" className="h-8 px-2 text-xs">
<a href={backHref}>
<ChevronLeft className="size-3" />
返回列表
</a>
</Button>
<div className="min-w-0 flex-1 truncate text-sm font-medium" title={pageTitle}>
{pageTitle}
</div>
{tabKind === "file" && runId && fileIndexValid && (
<Button asChild variant="outline" size="sm" className="h-8 px-2 text-xs">
<a
href={publicRunFileDownloadUrl(runId, fileIndexValue)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-3" />
下载
</a>
</Button>
)}
</div>
<div className="min-h-0 flex-1">
{!runId || !taskId ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
缺少任务或执行记录 ID。
</div>
) : isLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载预览...
</div>
) : isError ? (
<div className="text-destructive flex h-full items-center justify-center text-sm">
加载失败:{error instanceof Error ? error.message : "未知错误"}
</div>
) : tabKind === "html" ? (
htmlContent ? (
<HtmlPreviewFrame html={htmlContent} className="h-full w-full border-0 bg-white" />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
{htmlLoading && <Loader2 className="size-4 animate-spin" />}
HTML 页面尚未生成或正在生成中。
</div>
)
) : tabKind === "file" ? (
fileLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件...
</div>
) : !fileIndexValid || fileIsError || !fileData ? (
<div className="text-destructive flex h-full items-center justify-center text-sm">
文件加载失败:{fileError instanceof Error ? fileError.message : "文件不存在或不可预览"}
</div>
) : (
<ScrollArea className="h-full">
<div className="mx-auto max-w-5xl p-6">
<PublicFullscreenPreviewContent
preview={{
mode: "file",
title: fileData.name,
file: fileData,
}}
/>
</div>
</ScrollArea>
)
) : (
<ScrollArea className="h-full">
<div className="mx-auto max-w-5xl p-6">
<PublicFullscreenPreviewContent
preview={{
mode:
run?.result?.format === "markdown" || run?.result?.format === "mixed"
? "markdown"
: "text",
title: run?.result?.title || "正文预览",
content: run?.result?.content || "无正文内容",
}}
/>
</div>
</ScrollArea>
)}
</div>
</div>
</TooltipProvider>
);
}