c3151d1078
原本模型配置在「模型」页、计费规则与支付渠道却塞在「分析 > 计费&用量」观测页里(该页 ~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>
319 lines
16 KiB
TypeScript
319 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|