Files
sundynix-agentix/sundynix-admin/src/pages/DatasourcesPage.tsx
T
Blizzard c3151d1078 refactor(admin): 配置按域收口到「系统配置」页,Usage 只留用量观测
原本模型配置在「模型」页、计费规则与支付渠道却塞在「分析 > 计费&用量」观测页里(该页
~1290 行,首屏全是表单、真正的用量观测在第四屏)。按「域」重组:

- 新建「系统配置」页,两个 Tab:
  · 模型 = 模型登记/激活/连通性测试 + 计费规则(汇率/硬拦截/按模型定价与权重)
  · 支付 = 支付渠道配置(兑换码/积分包/微信参数) + 订单流水(订单流/对账/人工退款)
  配了什么就在同处看它的规则与数据,不用来回跳。三块直接复用原组件,零重写。
- 「计费 & 用量」瘦成纯用量观测(消耗趋势/租户排行/发放积分)。
- 导航「模型」页并入系统配置(12 页 → 11),侧栏图标换齿轮。
- 顺带修残留 bug:数据源页「前往模型管理」原为 href=#/models + onClick 改 hash,
  既指向已废弃路径、又是 hash 写法(早已换 BrowserRouter,点了不跳),改正规 <Link to=/admin/settings>。

tsc + build + vitest 41 过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:19:32 +08:00

319 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
const KIND_LABEL: Record<string, string> = { general: "通用", folder: "文件夹", project: "项目", case: "案例" };
const fmtWords = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(1)}` : `${n}`);
export function DatasourcesPage() {
const [counts, setCounts] = useState({ users: 0, kbs: 0, docs: 0 });
const [rows, setRows] = useState<DatasourceKB[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
// 运行时的 active embedding 模型展示
const [activeEmbedding, setActiveEmbedding] = useState<string>("加载中…");
// 运维工具折叠状态
const [opsOpen, setOpsOpen] = useState(false);
// 迁移 Modal 状态
const [migrationModal, setMigrationModal] = useState<"none" | "confirm" | "running" | "done">("none");
const [migrationResult, setMigrationResult] = useState<MigrateKbResult | null>(null);
const [migrationErr, setMigrationErr] = useState("");
useEffect(() => {
adminDatasources()
.then((r) => {
setCounts(r.counts);
setRows(r.datasources);
setErr("");
})
.catch((e) => setErr((e as Error).message))
.finally(() => setLoading(false));
// 获取激活的 embedding 模型
listModels("embedding")
.then((models) => {
const active = models.find((m) => m.active);
if (active) {
setActiveEmbedding(`${active.model} (${active.provider})`);
} else {
setActiveEmbedding("未配置/未激活 (将回退代码默认)");
}
})
.catch(() => setActiveEmbedding("获取失败"));
}, []);
const startMigration = async () => {
setMigrationModal("running");
setMigrationErr("");
try {
const res = await migrateKbStorage();
setMigrationResult(res);
setMigrationModal("done");
} catch (e) {
setMigrationErr((e as Error).message);
setMigrationModal("confirm"); // 退回到确认态展示错误
}
};
const totalWords = rows.reduce((a, r) => a + r.total_words, 0);
return (
<div className="flex flex-col gap-6">
{/* 顶部激活模型只读展示 & 引导 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-gray-700"> (Embedding)</h4>
<div className="mt-1 flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
</div>
</div>
<Link
to="/admin/settings"
className="flex items-center gap-1 text-xs font-medium text-violet-600 hover:text-violet-700 hover:underline"
>
</Link>
</div>
{/* 运维工具 (RAG 迁移) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<button
onClick={() => setOpsOpen(!opsOpen)}
className="flex w-full items-center justify-between font-semibold text-gray-700 focus:outline-none"
>
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm font-semibold text-gray-700"></span>
</div>
<span className="text-xs text-gray-400">{opsOpen ? "收起 ▲" : "展开 ▼"}</span>
</button>
{opsOpen && (
<div className="mt-4 border-t border-gray-100 pt-4 space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 text-xs bg-amber-50/50 border border-amber-100 p-4 rounded-lg">
<div className="space-y-1">
<div className="font-semibold text-amber-800 flex items-center gap-1.5">
<span></span>
<span className="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded text-[9px] font-bold"></span>
</div>
<p className="text-gray-500 leading-relaxed max-w-xl">
<code className="bg-gray-100 px-1 rounded">owner/kb_id/doc</code> <code className="bg-gray-100 px-1 rounded">space_id/kb_id/doc</code>
</p>
</div>
<button
onClick={() => setMigrationModal("confirm")}
className="self-start md:self-center shrink-0 bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold py-2 px-4 rounded-lg shadow-sm transition"
>
</button>
</div>
</div>
)}
</div>
{/* 迁移 Modal */}
{migrationModal !== "none" && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="relative mx-4 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h3 className="text-base font-semibold text-gray-900">
{migrationModal === "confirm" && "确认开始存储迁移?"}
{migrationModal === "running" && "正在迁移中…"}
{migrationModal === "done" && "迁移完成 🎉"}
</h3>
<p className="mt-0.5 text-xs text-gray-400"> KB space_id</p>
</div>
{migrationModal !== "running" && (
<button
onClick={() => setMigrationModal("none")}
className="text-xl leading-none text-gray-400 hover:text-gray-600"
>
×
</button>
)}
</div>
<div className="space-y-4 px-6 py-5 text-sm">
{migrationModal === "confirm" && (
<>
<p className="text-gray-600 leading-relaxed">
Space
</p>
<div className="rounded-lg border border-amber-100 bg-amber-50 p-3 text-xs text-amber-700 space-y-1">
<div className="font-semibold"> </div>
<div>· </div>
<div>· <code className="font-mono bg-amber-100/50 px-1">sundynix_doc</code> </div>
<div>· </div>
</div>
{migrationErr && (
<div className="rounded-lg border border-rose-100 bg-rose-50 px-3 py-2 text-xs text-rose-600">
{migrationErr}
</div>
)}
</>
)}
{migrationModal === "running" && (
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<svg className="animate-spin h-8 w-8 text-amber-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-xs text-gray-500 font-medium animate-pulse"></span>
</div>
)}
{migrationModal === "done" && migrationResult && (
<div className="space-y-3">
<div className="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
RRF
</div>
<div className="divide-y divide-gray-100 border rounded-xl overflow-hidden bg-gray-50/50">
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-800 font-mono">{migrationResult.total}</span>
</div>
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-amber-600 font-mono">{migrationResult.enqueued}</span>
</div>
<div className="flex justify-between p-3 text-xs">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-600 font-mono">{migrationResult.skipped}</span>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4">
{migrationModal === "confirm" && (
<>
<button
onClick={() => setMigrationModal("none")}
className="rounded-lg border border-gray-200 px-4 py-2 text-xs text-gray-500 hover:bg-gray-50"
>
</button>
<button
onClick={() => void startMigration()}
className="rounded-lg bg-amber-600 hover:bg-amber-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
>
</button>
</>
)}
{migrationModal === "done" && (
<button
onClick={() => setMigrationModal("none")}
className="rounded-lg bg-violet-600 hover:bg-violet-700 px-4 py-2 text-xs font-semibold text-white shadow-sm transition"
>
</button>
)}
</div>
</div>
</div>
)}
{/* 检索管线说明(诚实:三路 + RRF 等权融合,非可调权重) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-gray-700">线</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Route color="violet" name="向量检索" impl="Milvus" desc="Embedding 相似度召回语义相关块" />
<Route color="cyan" name="全文检索" impl="Bleve" desc="倒排索引召回关键词精确命中" />
<Route color="emerald" name="图谱检索" impl="Neo4j" desc="实体三元组召回结构化关联" />
</div>
<p className="mt-3 text-[11px] leading-relaxed text-gray-400">
<span className="font-medium text-gray-600">RRF </span>Reciprocal Rank Fusion k=60 rerank
<code className="rounded bg-gray-100 px-1">SearchByMode</code> mcp-go
</p>
</div>
{/* 平台数据源计数 */}
<div className="grid grid-cols-3 gap-4">
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
<Stat label="文档总数" value={String(counts.docs)} tone="cyan" sub={`${fmtWords(totalWords)}`} />
<Stat label="平台用户" value={String(counts.users)} tone="emerald" />
</div>
{/* 知识库清单(真数据) */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-sm font-semibold text-gray-700"></h3>
{loading ? (
<div className="py-8 text-center text-xs text-gray-400"></div>
) : err ? (
<div className="py-8 text-center text-xs text-rose-500">{err}</div>
) : rows.length === 0 ? (
<div className="py-8 text-center text-xs text-gray-400"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 font-medium text-gray-800">{r.name}</td>
<td className="py-2 pr-3"><span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">{KIND_LABEL[r.kind] ?? r.kind}</span></td>
<td className="py-2 pr-3 text-xs text-gray-500">{r.tenant_name || r.tenant_id || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-700">{r.doc_count}</td>
<td className="py-2 text-right tabular-nums text-gray-500">{fmtWords(r.total_words)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
const ROUTE_TONE: Record<string, { dot: string; text: string }> = {
violet: { dot: "bg-violet-500", text: "text-violet-600" },
cyan: { dot: "bg-cyan-500", text: "text-cyan-600" },
emerald: { dot: "bg-emerald-500", text: "text-emerald-600" },
};
function Route({ color, name, impl, desc }: { color: string; name: string; impl: string; desc: string }) {
const t = ROUTE_TONE[color];
return (
<div className="rounded-lg border border-gray-100 bg-gray-50/50 p-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${t.dot}`} />
<span className="text-sm font-medium text-gray-700">{name}</span>
<span className={`text-[10px] ${t.text}`}>{impl}</span>
</div>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">{desc}</p>
</div>
);
}
const STAT_TONE: Record<string, string> = { violet: "text-violet-600", cyan: "text-cyan-600", emerald: "text-emerald-600" };
function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone: string }) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className={`mt-1 text-2xl font-semibold tabular-nums ${STAT_TONE[tone] ?? "text-gray-800"}`}>{value}</div>
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
</div>
);
}