feat: 完善公开任务预览和用户问答历史
This commit is contained in:
1 parent
e7c745a512
commit
0001f86826
5 files changed
+984
-109
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user