refactor(desktop,gateway): 用户端去重 —— 一个功能一个入口,一份数据一个查法

用户反馈「UI 太乱」。数出来的:report/kb/memory 各 4 个入口、studio 5 个;
运行历史两套渲染。乱的机制是同一件事写了两遍,改一处另一处必然掉队。

**一份数据一个查法**
- 工作台「最近任务」和运行页「运行历史」是同一批数据,却各查各的:
  前者走 store.RecentTasks(),后者走 RecentRuns()。于是我给运行历史加的
  topic 字段工作台完全没跟上,还在 mono 显示 report_<hex>;而且 RecentTasks
  没有租户过滤,口径也不一致。现在统一走 RecentRuns,RecentTasks 删掉
  (它只有那一个调用点)。前端 recent_runs 也改用 RunSummary 同一个类型。

**一个功能一个入口**
- 工作台删掉「入库知识/生成报告/管理记忆」按钮排 —— 跳的目标和上面四张
  能力卡片完全重合,同一页给两个入口。
- 「最近任务」现在点击直达运行页对应那条(App 加 goto(view, taskId) →
  RunsView focusTaskId),而不是只把页面切过去让用户自己再找一遍。

**报告归 RUN,只做启动器**
- 报告是「执行」不是「构建」,从 BUILD 组移到 RUN。
- 报告页砍掉「执行轨迹」「报告正文」两个面板 —— 和运行页完全重复,而且
  那套是组件本地 state,切页面就没。现在它只有主题输入框。
- 报告改走 App 的全局运行态(新增 onRunReport,与 onRun 同构、复用 attachRun):
  提交 → 跳「运行 · 观测」→ 实时看轨迹/正文/导出。App 里 onRun 早就写着
  「发起即跳运行页,观测统一收敛在此页」——这条方针一直在,只有报告没遵守。

**顺带清死代码**
- 市场(规划中)从导航下架,ViewKey 里的 "market" 和 Boxes 图标一并删。
- PLACEHOLDERS 整块死代码:它列的 home/kb/report/runs 全都早已真实实现,
  market 是唯一还可能用到的、下架后那个 Placeholder 分支永远走不到
  (tsc 报 "Spread types" 就是因为 PLACEHOLDERS[view] 已成 never)。
  连同 views/Placeholder.tsx 一起删。

live 验证(真账号,桌面端实机):报告页输入主题 → 提交 → 自动跳运行页 →
顶部「当前运行 · live」→ 轨迹实时跑 → 报告正文流式出 + 导出按钮在位 →
report_7a0110a10b534f90 落库带 topic「归口测试」,POST /api/v1/reports → 202。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 14:08:16 +08:00
parent fd99ba6226
commit 759483bf92
9 changed files with 110 additions and 212 deletions
+36 -16
View File
@@ -12,11 +12,10 @@ import { ReportView } from "./views/ReportView";
import { RunsView } from "./views/RunsView";
import { UsageView } from "./views/UsageView";
import { Home } from "./views/Home";
import { Placeholder } from "./views/Placeholder";
import { CommandPalette, type Command } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner";
import { Login } from "./views/Login";
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
import { submitTask, generateReport, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, myTenants, switchTenant, spaceCurrent, mySpaces, switchSpace, createSpace, type Identity, type AuthUser, type TenantCtx, type MyTenant, type SpaceCtx, type MySpace } from "./lib/api";
import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run";
import { ToastProvider } from "./ui";
@@ -31,16 +30,11 @@ function getSessionId(): string {
return s;
}
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" },
report: { title: "报告生成", desc: "模板库 · 大纲编辑 · 章节并行生成进度 · 实时预览(含引用) · 导出 docx/pdf。依赖 RAG 核心链 + UniOffice。" },
runs: { title: "运行 · 观测", desc: "实时执行 · 节点轨迹 · 工具调用 · 运行历史复盘。当前运行结果见底部抽屉。" },
market: { title: "市场 · Packs(规划中)", desc: "垂直包(法律/医疗/金融) · Agent 模板 · 开通向导(建租户→入库→注册模板→应用配置)。依赖多租户 + Pack 格式。" },
};
export default function App() {
const [view, setView] = useState<ViewKey>("home");
// 从工作台「最近任务」点进来时要定位到具体那条运行,而不是只把页面切过去。
const [focusRun, setFocusRun] = useState<string | null>(null);
const goto = (v: ViewKey, taskId?: string) => { setView(v); setFocusRun(taskId ?? null); };
const [user, setUser] = useState<AuthUser | null>(null);
const [tenant, setTenant] = useState<TenantCtx | null>(null);
const [tenants, setTenants] = useState<MyTenant[]>([]);
@@ -252,6 +246,34 @@ export default function App() {
[identity, attachRun],
);
// 报告生成走和编排任务同一条路:灌进全局运行态 + 跳「运行 · 观测」。
// 此前报告页自己维护一套 SSE/输出/轨迹本地 state,于是运行页看不到它、
// 切个页面这套 state 就没了 —— 报告明明在后端跑完了却永远找不回。
const onRunReport = useCallback(
async (topic: string, kb?: string) => {
closeRef.current?.();
execCloseRef.current?.();
stopPoll();
const t0 = Date.now();
setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交报告任务" }], exec: [] });
setFocusRun(null);
setView("runs");
try {
const taskId = await generateReport(identity, topic, kb);
setRun((r) => ({
...r,
phase: "streaming",
taskId,
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
}));
attachRun(taskId, t0);
} catch (e) {
setRun((r) => ({ ...r, phase: "error", error: (e as Error).message }));
}
},
[identity, attachRun],
);
// 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现,
// 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。
const restoredRef = useRef(false);
@@ -303,22 +325,20 @@ export default function App() {
<LeftNav active={view} onSelect={setView} />
<main className="min-w-0 flex-1 overflow-hidden">
{view === "home" ? (
<Home onSelect={setView} userName={user.name || user.email} spaceName={space?.space?.name} />
<Home onSelect={goto} userName={user.name || user.email} spaceName={space?.space?.name} />
) : view === "studio" ? (
<StudioView onRun={onRun} phase={run.phase} identity={identity} readOnly={tenant?.role === "viewer"} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
) : view === "kb" ? (
<KbView identity={identity} spaceId={space?.space?.id ?? ""} spaceReadOnly={space?.role === "viewer"} />
) : view === "report" ? (
<ReportView identity={identity} />
<ReportView onRunReport={onRunReport} running={run.phase === "submitting" || run.phase === "streaming"} />
) : view === "runs" ? (
<RunsView run={run} />
<RunsView run={run} focusTaskId={focusRun} />
) : view === "memory" ? (
<MemoryView identity={identity} />
) : view === "usage" ? (
<UsageView />
) : (
<Placeholder {...(PLACEHOLDERS[view] ?? { title: "模块", desc: "规划中。" })} />
)}
) : null}
</main>
</div>
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
+1 -2
View File
@@ -648,7 +648,6 @@ export async function deleteMemory(id: Identity, key: string): Promise<void> {
// ── 工作台仪表盘聚合(GET /stats/overview)──
export interface KeyCount { key: string; count: number }
export interface RecentRun { task_id: string; status: string; detail: string; at: string }
export interface Overview {
tasks_today: number;
tasks_total: number;
@@ -662,7 +661,7 @@ export interface Overview {
kb_count: number;
tokens_today: number;
daily_budget: number;
recent_runs: RecentRun[];
recent_runs: RunSummary[]; // 与「运行」页共用类型,后端也共用 RecentRuns
services: Record<string, boolean>;
}
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { cn } from "../ui";
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "usage" | "market";
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "usage";
interface Item {
key: ViewKey;
@@ -25,11 +25,11 @@ const ITEMS: Item[] = [
{ key: "home", label: "工作台", icon: LayoutDashboard, ready: true },
{ key: "studio", label: "编排", icon: Workflow, group: "BUILD", ready: true },
{ key: "kb", label: "知识库", icon: Database, group: "BUILD", ready: true },
{ key: "report", label: "报告", icon: FileText, group: "BUILD", ready: true },
{ key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
// 报告是「执行」不是「构建」:它归 RUN。放 BUILD 里跟编排/知识库并列会让人以为它是个要搭的东西。
{ key: "report", label: "报告", icon: FileText, group: "RUN", ready: true },
{ key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
{ key: "usage", label: "用量", icon: Coins, group: "MANAGE", ready: true },
{ key: "market", label: "市场", icon: Boxes, group: "MANAGE", ready: false },
];
// 左导航:深色,激活态紫色高亮 + 左侧光条,描线图标。
+14 -15
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Workflow, Database, Bookmark, FileText, Plus, Upload, Activity, Coins, Gauge, ArrowRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import { statsOverview, type Overview, type RecentRun } from "../lib/api";
import { Workflow, Database, Bookmark, FileText, Plus, Activity, Coins, Gauge, ArrowRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import { statsOverview, type Overview, type RunSummary } from "../lib/api";
import type { ViewKey } from "../shell/LeftNav";
import { Button, cn } from "../ui";
@@ -61,15 +61,19 @@ function relTime(iso: string): string {
return `${Math.floor(d / 86_400_000)} 天前`;
}
function RunRow({ r }: { r: RecentRun }) {
// 一条最近运行。点击直达「运行」页对应那条 —— 光切页面还得让用户自己再找一遍。
// 有主题(报告类)就显示主题;否则退回 task_idreport_<hex> 这种 id 对用户没有任何意义。
function RunRow({ r, onOpen }: { r: RunSummary; onOpen: () => void }) {
const s = STATUS_LABEL[r.status] ?? { text: r.status, cls: "text-slate-500" };
return (
<div className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-ink-850">
<button onClick={onOpen} className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-ink-850">
<span className={cn("h-1.5 w-1.5 flex-none rounded-full", s.cls.replace("text-", "bg-"))} />
<span className="flex-1 truncate font-mono text-xs text-slate-400">{r.task_id}</span>
<span className={cn("flex-1 truncate text-xs", r.topic ? "text-slate-300" : "font-mono text-slate-400")}>
{r.topic || r.task_id}
</span>
<span className={cn("text-[11px]", s.cls)}>{s.text}</span>
<span className="w-16 text-right text-[11px] text-slate-600">{relTime(r.at)}</span>
</div>
</button>
);
}
@@ -79,7 +83,7 @@ function greet(): string {
}
// 工作台:商业级门面 —— 问候 + 主行动 + 关键指标 + 最近任务 + 快速开始(去基建、去黑话)。
export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey) => void; userName?: string; spaceName?: string }) {
export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey, taskId?: string) => void; userName?: string; spaceName?: string }) {
const [ov, setOv] = useState<Overview | null>(null);
useEffect(() => {
let alive = true;
@@ -132,7 +136,7 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey)
)}
</div>
{hasRuns ? (
<div className="mt-1">{ov!.recent_runs.map((r) => <RunRow key={r.task_id} r={r} />)}</div>
<div className="mt-1">{ov!.recent_runs.map((r) => <RunRow key={r.task_id} r={r} onOpen={() => onSelect("runs", r.task_id)} />)}</div>
) : (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<div className="text-sm text-slate-400"></div>
@@ -175,13 +179,8 @@ export function Home({ onSelect, userName, spaceName }: { onSelect: (v: ViewKey)
})}
</div>
</div>
{/* 次要动作 */}
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" icon={Upload} onClick={() => onSelect("kb")}></Button>
<Button size="sm" icon={FileText} onClick={() => onSelect("report")}></Button>
<Button size="sm" icon={Bookmark} onClick={() => onSelect("memory")}></Button>
</div>
{/* 原本这里还有一排「入库知识/生成报告/管理记忆」按钮,跳的目标和上面四张卡片
完全重合 —— 同一页里同一个功能给两个入口,纯冗余,已删。 */}
</div>
</div>
);
@@ -1,20 +0,0 @@
import { Hammer } from "lucide-react";
import { Badge } from "../ui";
// 规划中模块占位 —— 深色,露出信息架构与定位。
export function Placeholder({ title, desc }: { title: string; desc: string }) {
return (
<div className="flex h-full items-center justify-center p-8">
<div className="max-w-md rounded-lg border border-dashed border-line bg-ink-900 p-8 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl border border-line bg-ink-850 text-slate-500">
<Hammer className="h-6 w-6" strokeWidth={1.5} />
</div>
<div className="mb-2 text-base font-medium text-slate-200">{title}</div>
<p className="text-sm leading-relaxed text-slate-500">{desc}</p>
<div className="mt-4">
<Badge tone="warn"></Badge>
</div>
</div>
</div>
);
}
@@ -1,151 +1,60 @@
import { useRef, useState } from "react";
import { Play, FileText, FileType2, Printer, FileCode } from "lucide-react";
import { generateReport, streamTokens, streamExec, reportExportUrl, reportFilename, type Identity, type ExecEvent } from "../lib/api";
import { saveReportAs, printReportHtml, notify } from "../lib/desktop";
import { ExecTrace } from "../components/ExecTrace";
import { Markdown } from "../components/Markdown";
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
import { useState } from "react";
import { FileText, Play } from "lucide-react";
import { Button, Panel } from "../ui";
type Phase = "idle" | "running" | "done" | "error";
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx
export function ReportView({ identity }: { identity: Identity }) {
const toast = useToast();
// 报告生成 —— 只做启动器:输入主题 → 提交 → 自动跳「运行 · 观测」实时看。
//
// 这里曾经自己维护一整套:SSE 订阅、token 流、执行轨迹、导出按钮、PDF 打印,
// 页面上还挂着「执行轨迹」和「报告正文」两个面板 —— 和运行页完全重复
// 而且那套 state 全是组件本地 useState:切个页面就灰飞烟灭,报告明明在后端
// 好好地跑完了,用户却永远找不回它。
// 现在轨迹/正文/导出统一由运行页负责(那份是持久的、能复盘、能导出)。
export function ReportView({ onRunReport, running }: { onRunReport: (topic: string, kb?: string) => void; running: boolean }) {
const [topic, setTopic] = useState("");
const [kb, setKb] = useState("");
const [phase, setPhase] = useState<Phase>("idle");
const [out, setOut] = useState("");
const [exec, setExec] = useState<ExecEvent[]>([]);
const [taskId, setTaskId] = useState("");
const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const running = phase === "running";
// 导出 Word / Markdown:后端按需现渲染(导出时再处理),经原生"另存为"或浏览器下载。
const exportFile = async (format: "docx" | "md") => {
try {
const p = await saveReportAs(reportExportUrl(taskId, format), reportFilename(topic, taskId, format));
if (p) toast.push("success", "已保存到 " + p);
} catch (e) {
toast.push("error", (e as Error).message);
}
};
// 导出 PDF:把预览到的 Markdown(已渲染 HTML)送进打印视图出 PDF(CJK 零字体依赖)。
const exportPdf = () => {
const html = previewRef.current?.innerHTML;
if (!html) {
toast.push("error", "暂无报告正文可导出");
return;
}
if (!printReportHtml(topic.trim() || taskId, html)) {
toast.push("error", "打印窗口被拦截,请允许弹出窗口后重试");
}
};
const onGenerate = async () => {
const submit = () => {
if (!topic.trim() || running) return;
closeRef.current?.();
execCloseRef.current?.();
setPhase("running");
setOut("");
setExec([]);
setTaskId("");
try {
const id = await generateReport(identity, topic.trim(), kb.trim() || undefined);
setTaskId(id);
execCloseRef.current = streamExec(
id,
(ev) => setExec((xs) => [...xs, ev]),
() => {},
() => {},
);
closeRef.current = streamTokens(
id,
(tok) => setOut((o) => o + tok),
() => {
setPhase("done");
toast.push("success", "报告已生成,可保存 Word");
notify("报告已生成", topic.trim());
},
() => {
setPhase("error");
toast.push("error", "报告流连接中断");
},
);
} catch (e) {
setPhase("error");
toast.push("error", (e as Error).message);
}
onRunReport(topic.trim(), kb.trim() || undefined);
};
const tracePhase = running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
<header>
<div className="h-full overflow-auto p-6">
<div className="mx-auto max-w-2xl">
<h1 className="text-lg font-semibold text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500">
+ LLM Word(.docx)
</p>
</header>
<div className="grid grid-cols-[1fr_220px_auto_auto] items-end gap-3 rounded-lg border border-line bg-ink-900 p-4 shadow-card">
<Field label="报告主题">
<Input
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onGenerate()}
placeholder="如:2026 年国产大模型产业现状与趋势分析"
/>
</Field>
<Field label="知识库(可选)">
<Input value={kb} onChange={(e) => setKb(e.target.value)} placeholder="如 docs,留空则不挂检索" />
</Field>
<Button variant="primary" icon={Play} onClick={onGenerate} disabled={running || !topic.trim()}>
{running ? "生成中…" : "生成报告"}
</Button>
{phase === "done" && taskId ? (
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500"></span>
<Button icon={FileType2} onClick={() => exportFile("docx")}>
Word
</Button>
<Button icon={Printer} onClick={exportPdf}>
PDF
</Button>
<Button variant="ghost" icon={FileCode} onClick={() => exportFile("md")}>
Markdown
</Button>
</div>
) : (
<span className="h-9" />
)}
</div>
<div className="grid min-h-0 flex-1 grid-cols-[340px_1fr] gap-4">
<Panel title="执行轨迹" icon={FileText}>
<ExecTrace events={exec} phase={tracePhase} />
</Panel>
<Panel
title={
<span className="flex items-center gap-2">
<Dot tone={running ? "running" : phase === "done" ? "success" : "neutral"} pulse={running} />
· {taskId || "未开始"}
</span>
}
>
{out ? (
<div ref={previewRef}>
<Markdown text={out} className="text-sm" />
<Panel title="新建报告" icon={FileText} className="mt-5">
<div className="space-y-4 p-1">
<div>
<label className="mb-1.5 block text-xs text-slate-400"></label>
<input
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="如:2026 年国产大模型产业现状与趋势分析"
className="w-full rounded-md border border-line bg-ink-850 px-3 py-2 text-sm text-slate-100 outline-none placeholder:text-slate-600 focus:border-brand/50"
/>
</div>
) : (
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
)}
<div>
<label className="mb-1.5 block text-xs text-slate-400"></label>
<input
value={kb}
onChange={(e) => setKb(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="如 docs,留空则不挂检索"
className="w-full rounded-md border border-line bg-ink-850 px-3 py-2 text-sm text-slate-100 outline-none placeholder:text-slate-600 focus:border-brand/50"
/>
</div>
<div className="flex items-center gap-3 pt-1">
<Button variant="primary" icon={Play} onClick={submit} disabled={running || !topic.trim()}>
{running ? "生成中…" : "生成报告"}
</Button>
<span className="text-[11px] text-slate-600"> Word</span>
</div>
</div>
</Panel>
</div>
</div>
@@ -29,7 +29,7 @@ function relTime(iso: string): string {
// 运行 · 观测:左=运行历史,中=tab 切换(轨迹/工具/评测)+状态,右=模型输出(Markdown)。
// 选中历史运行从 Redis 回放;「当前运行」置顶沿用实时订阅。
export function RunsView({ run }: { run: RunState }) {
export function RunsView({ run, focusTaskId }: { run: RunState; focusTaskId?: string | null }) {
const toast = useToast();
const [runs, setRuns] = useState<RunSummary[]>([]);
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
@@ -63,6 +63,11 @@ export function RunsView({ run }: { run: RunState }) {
if (run.taskId) { setSel(null); setEvalRes(null); setTab("trace"); }
}, [run.taskId]);
// 从工作台「最近任务」点进来:直接定位到那一条,别让用户切过来再自己找一遍。
useEffect(() => {
if (focusTaskId) selectRun(focusTaskId);
}, [focusTaskId, selectRun]);
// 当前实时运行的评测:完成后拉一次。
useEffect(() => {
if (!sel && run.taskId && run.phase === "done") taskEval(run.taskId).then(setEvalRes).catch(() => {});
@@ -453,13 +453,10 @@ func (h *Handler) StatsOverview(c *gin.Context) {
})
}
// 近期运行 feed。
recent := make([]gin.H, 0, 8)
for _, t := range h.db.RecentTasks(ctx, uid, 8) {
recent = append(recent, gin.H{
"task_id": t.TaskID, "status": t.Status, "detail": t.Detail, "at": t.CreatedAt,
})
}
// 近期运行 feed —— 与「运行」页的运行历史共用 RecentRuns,同一份数据不能有两个查法
// 此前这里走 RecentTasks(),于是运行历史加了 topic 字段、工作台完全没跟上,
// 还在显示 report_<hex>;而且 RecentTasks 没有租户过滤,口径也不一致。
recent := h.db.RecentRuns(ctx, uid, 8)
// 服务健康(与 Health 同口径:本地可判 + milvus/neo4j 经 mcp-go)。
services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false}
+3 -14
View File
@@ -275,19 +275,8 @@ func (p *Postgres) StatsOverview(ctx context.Context, owner string) *Overview {
return o
}
// RecentTasks 返回最近 n 条任务(工作台「近期运行」feed)。
// RecentTasks 返回某用户最近 n 条任务(个人工作台「近期运行」feed)。
// owner 过滤"我的运行";tenant 由插件自动叠加(双保险:跨用户/跨租户都隔离)。
func (p *Postgres) RecentTasks(ctx context.Context, owner string, n int) []Task {
if p.db == nil {
return nil
}
var out []Task
p.db.WithContext(ctx).Where("owner = ?", owner).Order("created_at desc").Limit(n).Find(&out)
return out
}
// RunRow 是「运行历史」一行:任务 + 其评测(LEFT JOIN,未评则 level 空)。
// 工作台「最近任务」与「运行」页共用它 —— 同一份数据只能有一个查法。
type RunRow struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
@@ -308,8 +297,8 @@ func (p *Postgres) RecentRuns(ctx context.Context, owner string, n int) []RunRow
}
var out []RunRow
q := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, " +
Select("t.task_id, t.status, t.detail, t.created_at as at, "+
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
"coalesce(t.graph->>'topic','') as topic").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.deleted_at is null AND t.owner = ?", owner)