feat(desktop): 工作台重做为实时仪表盘 + /stats/overview 聚合端点(Tier1 UI 升级)
桌面端首屏原是静态宣传页(stat 全硬编码、唯一活数据是网关在线),"太单调"。 重做为活的驾驶舱: 后端 GET /api/v1/stats/overview(聚合,几条轻量查询): - 任务今日/累计、7 日趋势、近 7 天终态分布(实例级,Task 无 owner) - 评测均分 + 忠实度均值 + 计数;知识库文档/库数(owner 级) - token 今日 + 7 日趋势(Redis 日计数)、近期运行 feed、服务健康(复用 health 口径) - store.StatsOverview + RecentTasks。 前端 Home 重写为仪表盘:4 指标卡(今日任务/Token/评测均分/知识库)带 SVG 火花线、 近期运行 feed(状态点+相对时间,点进运行观测)、7 日任务量柱图、服务健康灯带、能力入口、 快捷动作。5s 轮询刷新。配色沿用现有 ink 暗底 + brand/accent。 live(vite dev + 预览截图):登录后仪表盘渲染真实活数据(今日任务/Token/评测 1.00/近期运行/ 服务全绿),无控制台报错。tsc+vite 构建通过,gateway 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -457,3 +457,29 @@ export async function deleteMemory(id: Identity, key: string): Promise<void> {
|
||||
throw new Error(d.error ?? `delete memory failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工作台仪表盘聚合(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;
|
||||
status_count: KeyCount[];
|
||||
task_trend: KeyCount[];
|
||||
token_trend: KeyCount[];
|
||||
eval_avg: number;
|
||||
faithful_avg: number;
|
||||
eval_count: number;
|
||||
kb_docs: number;
|
||||
kb_count: number;
|
||||
tokens_today: number;
|
||||
daily_budget: number;
|
||||
recent_runs: RecentRun[];
|
||||
services: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export async function statsOverview(): Promise<Overview> {
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: bearer() }));
|
||||
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
||||
return res.json() as Promise<Overview>;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,187 @@
|
||||
import { Workflow, Database, Bookmark, FileText, Plus, Upload, ArrowRight, type LucideIcon } from "lucide-react";
|
||||
import { useHealth } from "../lib/health";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Workflow, Database, Bookmark, FileText, Plus, Upload, Activity, Coins, Gauge, ArrowRight, type LucideIcon } from "lucide-react";
|
||||
import { statsOverview, type Overview, type RecentRun } from "../lib/api";
|
||||
import type { ViewKey } from "../shell/LeftNav";
|
||||
import { Button, cn } from "../ui";
|
||||
|
||||
function Stat({ label, value, sub, accent }: { label: string; value: string; sub: string; accent: string }) {
|
||||
// 迷你火花线:把一串数值画成 SVG 折线(仪表盘趋势)。
|
||||
function Spark({ data, stroke }: { data: number[]; stroke: string }) {
|
||||
if (data.length < 2) return null;
|
||||
const max = Math.max(...data, 1);
|
||||
const min = Math.min(...data, 0);
|
||||
const span = max - min || 1;
|
||||
const w = 60;
|
||||
const h = 18;
|
||||
const pts = data
|
||||
.map((v, i) => `${(i / (data.length - 1)) * w},${h - ((v - min) / span) * (h - 2) - 1}`)
|
||||
.join(" ");
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} aria-hidden>
|
||||
<polyline points={pts} fill="none" stroke={stroke} strokeWidth={1.5} strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value, sub, spark, sparkColor, accent }: {
|
||||
icon: LucideIcon; label: string; value: string; sub: string;
|
||||
spark?: number[]; sparkColor?: string; accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-ink-850 p-4">
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className={cn("mt-1 text-2xl font-semibold", accent)}>{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">{sub}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<Icon className="h-4 w-4 text-slate-600" strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className={cn("mt-1 text-2xl font-semibold tabular-nums", accent ?? "text-slate-100")}>{value}</div>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-slate-500">{sub}</span>
|
||||
{spark && spark.some((v) => v > 0) ? <Spark data={spark} stroke={sparkColor ?? "#a78bfa"} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CAPS: { icon: LucideIcon; title: string; desc: string; to: ViewKey; color: string }[] = [
|
||||
{ icon: Workflow, title: "Agent 编排", desc: "React Flow 画布 → Eino 动态图 → 流式执行", to: "studio", color: "text-brand-400" },
|
||||
{ icon: Database, title: "RAG 知识库", desc: "多文件入库 + 向量/全文/图谱 三路混合检索", to: "kb", color: "text-accent-400" },
|
||||
{ icon: FileText, title: "报告生成", desc: "规划大纲 → 各章并行检索撰写 → 渲染 Word", to: "report", color: "text-indigo-300" },
|
||||
{ icon: Bookmark, title: "偏好记忆", desc: "画像 + 多轮历史,让模型“知道是你”", to: "memory", color: "text-success" },
|
||||
{ icon: Workflow, title: "Agent 编排", desc: "画布 → Eino 动态图 → 流式执行", to: "studio", color: "text-brand-400" },
|
||||
{ icon: Database, title: "RAG 知识库", desc: "向量 / 全文 / 图谱 三路混合检索", to: "kb", color: "text-accent-400" },
|
||||
{ icon: FileText, title: "报告生成", desc: "规划 → 分章并行 → 渲染 Word", to: "report", color: "text-indigo-300" },
|
||||
{ icon: Bookmark, title: "偏好记忆", desc: "画像 + 多轮历史", to: "memory", color: "text-success" },
|
||||
];
|
||||
|
||||
// 工作台:平台概览 + 快捷入口(深色 AI 控制台首页)。
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
done: "bg-success",
|
||||
failed: "bg-danger", timeout: "bg-danger", rejected: "bg-danger",
|
||||
waiting: "bg-warn",
|
||||
running: "bg-brand", submitted: "bg-brand",
|
||||
};
|
||||
|
||||
const SERVICE_LABEL: Record<string, string> = {
|
||||
gateway: "网关", nats: "总线", db: "PG", redis: "Redis", milvus: "Milvus", neo4j: "Neo4j",
|
||||
};
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 60_000) return "刚刚";
|
||||
if (d < 3_600_000) return `${Math.floor(d / 60_000)} 分钟前`;
|
||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)} 小时前`;
|
||||
return `${Math.floor(d / 86_400_000)} 天前`;
|
||||
}
|
||||
|
||||
function RunRow({ r }: { r: RecentRun }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-line/60 py-2 last:border-0">
|
||||
<span className={cn("h-1.5 w-1.5 flex-none rounded-full", STATUS_DOT[r.status] ?? "bg-ink-600")} />
|
||||
<span className="flex-1 truncate font-mono text-xs text-slate-300">{r.task_id}</span>
|
||||
<span className="text-[11px] text-slate-500">{r.status}</span>
|
||||
<span className="w-16 text-right text-[11px] text-slate-600">{relTime(r.at)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 工作台:实时驾驶舱(指标 + 趋势 + 近期运行 + 服务健康 + 快捷动作)。
|
||||
export function Home({ onSelect }: { onSelect: (v: ViewKey) => void }) {
|
||||
const h = useHealth();
|
||||
const [ov, setOv] = useState<Overview | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => statsOverview().then((d) => alive && setOv(d)).catch(() => {});
|
||||
load();
|
||||
const id = setInterval(load, 5000);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const taskTrend = ov?.task_trend.map((d) => d.count) ?? [];
|
||||
const tokenTrend = ov?.token_trend.map((d) => d.count) ?? [];
|
||||
const maxTask = Math.max(...taskTrend, 1);
|
||||
const fmtTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-brand to-accent text-lg font-bold text-white shadow-glow">
|
||||
S
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-brand to-accent text-lg font-bold text-white shadow-glow">S</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="brand-gradient text-2xl font-bold leading-tight">工作台</h1>
|
||||
<p className="text-sm text-slate-500">实时概览 · 编排 / 知识库 / 报告 / 记忆</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="brand-gradient text-2xl font-bold leading-tight">sundynix-agentix</h1>
|
||||
<p className="text-sm text-slate-500">分层式 AI Agent 平台 · 编排 / 知识库 / 报告 / 记忆</p>
|
||||
<span className="flex items-center gap-1.5 rounded-full border border-line bg-ink-850 px-3 py-1 text-[11px] text-slate-500">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", ov?.services.gateway ? "bg-success" : "bg-danger")} />
|
||||
{ov?.services.gateway ? "实时" : "连接中"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 指标卡 */}
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Metric icon={Activity} label="今日任务" value={`${ov?.tasks_today ?? 0}`} sub={`累计 ${ov?.tasks_total ?? 0}`}
|
||||
spark={taskTrend} sparkColor="#34d399" accent="text-brand-400" />
|
||||
<Metric icon={Coins} label="今日 Token" value={fmtTokens(ov?.tokens_today ?? 0)}
|
||||
sub={ov?.daily_budget ? `预算 ${fmtTokens(ov.daily_budget)}` : "未限额"} spark={tokenTrend} sparkColor="#a78bfa" accent="text-accent-400" />
|
||||
<Metric icon={Gauge} label="评测均分" value={(ov?.eval_avg ?? 0).toFixed(2)}
|
||||
sub={`忠实 ${(ov?.faithful_avg ?? 0).toFixed(2)} · ${ov?.eval_count ?? 0} 次`} accent="text-success" />
|
||||
<Metric icon={Database} label="知识库" value={`${ov?.kb_docs ?? 0}`} sub={`${ov?.kb_count ?? 0} 库 · 三路检索`} accent="text-indigo-300" />
|
||||
</div>
|
||||
|
||||
{/* 近期运行 + 7 日趋势 */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1.6fr_1fr]">
|
||||
<div className="rounded-lg border border-line bg-ink-900 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-300">近期运行</span>
|
||||
<button onClick={() => onSelect("runs")} className="flex items-center gap-1 text-xs text-brand-400 transition hover:opacity-80">
|
||||
全部 <ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{ov && ov.recent_runs.length > 0 ? (
|
||||
ov.recent_runs.map((r) => <RunRow key={r.task_id} r={r} />)
|
||||
) : (
|
||||
<div className="py-8 text-center text-xs text-slate-600">暂无运行记录</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-ink-900 p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-300">7 日任务量</div>
|
||||
<div className="flex h-24 items-end gap-2">
|
||||
{(ov?.task_trend ?? []).map((d, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col items-center gap-1.5">
|
||||
<div className="w-full rounded-t bg-brand/70 transition-all" style={{ height: `${Math.max((d.count / maxTask) * 100, 4)}%` }} title={`${d.count}`} />
|
||||
<span className="text-[10px] text-slate-600">{d.key.slice(-2)}</span>
|
||||
</div>
|
||||
))}
|
||||
{!ov && <div className="flex-1 text-center text-xs text-slate-600">加载中…</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Stat label="对话模型" value="DeepSeek" sub="chat · 控制面" accent="text-brand-400" />
|
||||
<Stat label="向量模型" value="百炼 v3" sub="embedding · 1024维" accent="text-accent-400" />
|
||||
<Stat label="混合检索" value="3 路" sub="向量+全文+图谱" accent="text-success" />
|
||||
<Stat label="网关" value={h.gateway ? "在线" : "离线"} sub={h.persisted ? "持久化就绪" : "降级"} accent={h.gateway ? "text-success" : "text-danger"} />
|
||||
{/* 服务健康灯带 */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 rounded-lg border border-line bg-ink-850 px-4 py-2.5">
|
||||
<span className="text-xs text-slate-500">服务</span>
|
||||
{Object.entries(SERVICE_LABEL).map(([k, label]) => (
|
||||
<span key={k} className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", ov?.services[k] ? "bg-success" : "bg-ink-600")} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 能力入口 */}
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{CAPS.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<button
|
||||
key={c.to}
|
||||
onClick={() => onSelect(c.to)}
|
||||
className="group rounded-lg border border-line bg-ink-850 p-5 text-left transition hover:border-brand/50 hover:bg-ink-800"
|
||||
>
|
||||
<Icon className={cn("h-6 w-6", c.color)} strokeWidth={1.8} />
|
||||
<div className="mt-3 font-medium text-slate-100">{c.title}</div>
|
||||
<button key={c.to} onClick={() => onSelect(c.to)}
|
||||
className="group rounded-lg border border-line bg-ink-850 p-4 text-left transition hover:border-brand/50 hover:bg-ink-800">
|
||||
<Icon className={cn("h-5 w-5", c.color)} strokeWidth={1.8} />
|
||||
<div className="mt-2.5 text-sm font-medium text-slate-100">{c.title}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-slate-500">{c.desc}</div>
|
||||
<div className="mt-3 flex items-center gap-1 text-xs text-brand-400 opacity-0 transition group-hover:opacity-100">
|
||||
进入 <ArrowRight className="h-3 w-3" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-lg border border-line bg-ink-900 p-5">
|
||||
<div className="mb-3 text-sm font-medium text-slate-300">快速开始</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" size="sm" icon={Plus} onClick={() => onSelect("studio")}>
|
||||
新建 Agent 编排
|
||||
</Button>
|
||||
<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 className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="primary" size="sm" icon={Plus} onClick={() => onSelect("studio")}>新建编排</Button>
|
||||
<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>
|
||||
|
||||
@@ -374,6 +374,53 @@ func (h *Handler) SetMemory(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
|
||||
}
|
||||
|
||||
// StatsOverview: GET /api/v1/stats/overview —— 工作台仪表盘聚合数据
|
||||
// (任务/评测实例级 + 知识库 owner 级 + token 用量 7 日 + 服务健康 + 近期运行)。
|
||||
func (h *Handler) StatsOverview(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
uid := userID(c)
|
||||
ov := h.db.StatsOverview(ctx, uid)
|
||||
|
||||
// token 用量:今日 + 近 7 日(按用户按天,来自 Redis 计数)。
|
||||
now := time.Now()
|
||||
today := now.Format("20060102")
|
||||
var tokenTrend []gin.H
|
||||
for i := 6; i >= 0; i-- {
|
||||
d := now.AddDate(0, 0, -i)
|
||||
tokenTrend = append(tokenTrend, gin.H{
|
||||
"key": d.Format("01-02"), "count": h.cache.GetUsage(ctx, uid, d.Format("20060102")),
|
||||
})
|
||||
}
|
||||
|
||||
// 近期运行 feed。
|
||||
recent := make([]gin.H, 0, 8)
|
||||
for _, t := range h.db.RecentTasks(ctx, 8) {
|
||||
recent = append(recent, gin.H{
|
||||
"task_id": t.TaskID, "status": t.Status, "detail": t.Detail, "at": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 服务健康(与 Health 同口径:本地可判 + milvus/neo4j 经 mcp-go)。
|
||||
services := gin.H{"gateway": true, "nats": true, "db": h.db.Enabled(), "redis": h.cache.Enabled(), "milvus": false, "neo4j": false}
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
if res, err := h.bus.CallTool(cctx, contract.ToolSubjectGo("health"), &contract.ToolCall{Tool: "health"}); err == nil && res != nil && res.OK {
|
||||
var sub map[string]bool
|
||||
if json.Unmarshal([]byte(res.Content), &sub) == nil {
|
||||
services["milvus"], services["neo4j"] = sub["milvus"], sub["neo4j"]
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks_today": ov.TasksToday, "tasks_total": ov.TasksTotal,
|
||||
"status_count": ov.StatusCount, "task_trend": ov.TaskTrend,
|
||||
"eval_avg": ov.EvalAvg, "faithful_avg": ov.FaithfulAvg, "eval_count": ov.EvalCount,
|
||||
"kb_docs": ov.KBDocs, "kb_count": ov.KBCount,
|
||||
"tokens_today": h.cache.GetUsage(ctx, uid, today), "daily_budget": userDailyTokenBudget(),
|
||||
"token_trend": tokenTrend, "recent_runs": recent, "services": services,
|
||||
})
|
||||
}
|
||||
|
||||
// ListMemory: GET /api/v1/memory —— 列出当前用户的全部偏好(结构化,供记忆面板)。
|
||||
func (h *Handler) ListMemory(c *gin.Context) {
|
||||
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("memory_list"),
|
||||
|
||||
@@ -72,6 +72,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||
p.GET("/billing", h.Billing)
|
||||
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
|
||||
}
|
||||
|
||||
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
|
||||
|
||||
@@ -180,6 +180,76 @@ func (p *Postgres) CountTasks(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DayCount 是「某天 / 某状态 → 计数」的一行(工作台趋势/分布用)。
|
||||
type DayCount struct {
|
||||
Key string `json:"key"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// Overview 是工作台概览的聚合数据。任务/评测为实例级(Task 无 owner,单租户部署即全量),
|
||||
// 知识库为 owner 级。降级模式(db==nil)返回零值。
|
||||
type Overview struct {
|
||||
TasksToday int64 `json:"tasks_today"`
|
||||
TasksTotal int64 `json:"tasks_total"`
|
||||
StatusCount []DayCount `json:"status_count"` // 近 7 天各终态分布
|
||||
TaskTrend []DayCount `json:"task_trend"` // 近 7 天每日任务数(MM-DD)
|
||||
EvalAvg float64 `json:"eval_avg"` // 综合分均值
|
||||
FaithfulAvg float64 `json:"faithful_avg"` // 忠实度均值(仅有来源的)
|
||||
EvalCount int64 `json:"eval_count"`
|
||||
KBDocs int64 `json:"kb_docs"` // owner 文档数
|
||||
KBCount int64 `json:"kb_count"` // owner 知识库数
|
||||
}
|
||||
|
||||
// StatsOverview 聚合工作台概览(几条轻量查询)。owner 用于知识库口径。
|
||||
func (p *Postgres) StatsOverview(ctx context.Context, owner string) *Overview {
|
||||
o := &Overview{StatusCount: []DayCount{}, TaskTrend: []DayCount{}}
|
||||
if p.db == nil {
|
||||
return o
|
||||
}
|
||||
db := p.db.WithContext(ctx)
|
||||
startOfDay := time.Now().Truncate(24 * time.Hour)
|
||||
|
||||
db.Model(&Task{}).Count(&o.TasksTotal)
|
||||
db.Model(&Task{}).Where("created_at >= ?", startOfDay).Count(&o.TasksToday)
|
||||
|
||||
// 近 7 天每日任务数(按日期分组,缺的天补 0 在前端/此处处理)。
|
||||
db.Model(&Task{}).
|
||||
Select("to_char(created_at, 'MM-DD') as key, count(*) as count").
|
||||
Where("created_at >= ?", time.Now().AddDate(0, 0, -6).Truncate(24*time.Hour)).
|
||||
Group("key").Order("key").Scan(&o.TaskTrend)
|
||||
|
||||
// 近 7 天终态分布。
|
||||
db.Model(&Task{}).
|
||||
Select("status as key, count(*) as count").
|
||||
Where("created_at >= ?", time.Now().AddDate(0, 0, -6)).
|
||||
Group("status").Scan(&o.StatusCount)
|
||||
|
||||
// 评测均值(综合 + 忠实度仅算有来源的)。
|
||||
var ev struct {
|
||||
Avg float64
|
||||
Faithful float64
|
||||
N int64
|
||||
}
|
||||
db.Model(&Eval{}).Select("coalesce(avg(overall),0) as avg, coalesce(avg(nullif(faithful,0)),0) as faithful, count(*) as n").Scan(&ev)
|
||||
o.EvalAvg, o.FaithfulAvg, o.EvalCount = ev.Avg, ev.Faithful, ev.N
|
||||
|
||||
if owner != "" {
|
||||
db.Model(&Doc{}).Where("owner = ?", owner).Count(&o.KBDocs)
|
||||
db.Model(&KB{}).Where("owner = ?", owner).Count(&o.KBCount)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// RecentTasks 返回最近 n 条任务(工作台「近期运行」feed)。
|
||||
func (p *Postgres) RecentTasks(ctx context.Context, n int) []Task {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
var out []Task
|
||||
p.db.WithContext(ctx).Order("created_at desc").Limit(n).Find(&out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 释放底层连接。
|
||||
func (p *Postgres) Close() {
|
||||
if p.db == nil {
|
||||
|
||||
Reference in New Issue
Block a user