From 79b110afd0aa5aa7384634bd8ef679644390aaec Mon Sep 17 00:00:00 2001
From: Blizzard
Date: Mon, 20 Jul 2026 11:17:54 +0800
Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=95=B0=E6=8D=AE=E6=BA=90?=
=?UTF-8?q?=E9=A1=B5=E8=BD=AC=20RAG=20=E8=BF=90=E7=BB=B4=E5=8F=B0=20+=20?=
=?UTF-8?q?=E6=94=AF=E4=BB=98/=E6=A8=A1=E5=9E=8B=E8=8F=9C=E5=8D=95?=
=?UTF-8?q?=E9=87=8D=E7=BB=84=20+=20=E6=A6=82=E8=A7=88=E5=8D=87=E7=BA=A7?=
=?UTF-8?q?=E4=B8=BA=E4=BB=AA=E8=A1=A8=E7=9B=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后端:
- 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路
(vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者
自己的 space,跨租户排障就没法做了)
- KB 清单补 space_id(检索键是 /,缺它前端拼不出 key)
前端:
- 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断,
召回不准时能直接定位是向量/分词/图谱哪一环挂了
- 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下;
导航支持二级菜单(NavParent 命中子路由自动展开)
- SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一
- 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection),
去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据)
- 全局隐藏滚动条(保留滚动)
Co-Authored-By: Claude Opus 4.8
---
sundynix-admin/src/api.ts | 26 +++
.../src/components/RetrievalBench.tsx | 178 ++++++++++++++++++
.../UsageSection.tsx} | 12 +-
sundynix-admin/src/index.css | 12 ++
sundynix-admin/src/pages/DashboardPage.tsx | 171 +++++++----------
sundynix-admin/src/pages/DatasourcesPage.tsx | 8 +-
sundynix-admin/src/pages/ModelConfigPage.tsx | 20 ++
.../src/pages/PaymentConfigPage.tsx | 15 ++
.../src/pages/PaymentOrdersPage.tsx | 15 ++
sundynix-admin/src/pages/SettingsPage.tsx | 64 -------
sundynix-admin/src/routes.test.tsx | 35 +++-
sundynix-admin/src/routes.tsx | 94 +++++----
sundynix-admin/src/shell/AppShell.tsx | 110 +++++++++--
sundynix-gateway/internal/handler/kb.go | 42 +++++
sundynix-gateway/internal/router/router.go | 1 +
.../internal/store/datasource_query.go | 11 +-
16 files changed, 577 insertions(+), 237 deletions(-)
create mode 100644 sundynix-admin/src/components/RetrievalBench.tsx
rename sundynix-admin/src/{pages/UsagePage.tsx => components/UsageSection.tsx} (95%)
create mode 100644 sundynix-admin/src/pages/ModelConfigPage.tsx
create mode 100644 sundynix-admin/src/pages/PaymentConfigPage.tsx
create mode 100644 sundynix-admin/src/pages/PaymentOrdersPage.tsx
delete mode 100644 sundynix-admin/src/pages/SettingsPage.tsx
diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts
index 6701de5..4f9b5a8 100644
--- a/sundynix-admin/src/api.ts
+++ b/sundynix-admin/src/api.ts
@@ -434,6 +434,8 @@ export async function guardrailEvents(limit = 100): Promise {
export interface DatasourceKB {
id: string;
name: string;
+ /** 检索作用域键前半段:三库里的库名是 "/",检索试验台按此定位。 */
+ space_id: string;
kind: string;
tenant_id: string;
tenant_name: string;
@@ -442,6 +444,30 @@ export interface DatasourceKB {
total_words: number;
}
+/** 检索命中:text=命中片段,score=该路的相似度/融合分(不同 mode 分数体系不同,勿跨路比较)。 */
+export interface KbHit {
+ text: string;
+ score: number;
+}
+
+/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank);""=生产链路(混合+rerank)。 */
+export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid";
+
+// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。
+// kb 传 `${space_id}/${name}`(来自 adminDatasources)。
+export async function adminKbSearch(kb: string, q: string, topK = 5, mode: SearchMode = ""): Promise {
+ const res = guard(
+ await fetch(`${ADMIN}/kb/search`, {
+ method: "POST",
+ headers: authHeaders(true),
+ body: JSON.stringify({ kb, q, topK, mode }),
+ }),
+ );
+ const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; error?: string };
+ if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`);
+ return d.hits ?? [];
+}
+
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string };
diff --git a/sundynix-admin/src/components/RetrievalBench.tsx b/sundynix-admin/src/components/RetrievalBench.tsx
new file mode 100644
index 0000000..2bf3649
--- /dev/null
+++ b/sundynix-admin/src/components/RetrievalBench.tsx
@@ -0,0 +1,178 @@
+import { useState } from "react";
+import { adminKbSearch, type DatasourceKB, type KbHit, type SearchMode } from "../api";
+
+// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
+// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
+// 图谱路空=Neo4j 未起或未抽三元组;融合有而生产为空=rerank 把结果滤掉了。
+//
+// ⚠️ 分数不可跨路比较:向量是余弦相似度(0~1),RRF 是 1/(k+rank) 的融合分(通常 <0.05)。
+
+const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
+ { mode: "vector", label: "向量", hint: "Milvus · 语义相似" },
+ { mode: "fulltext", label: "全文", hint: "Bleve · 关键词倒排" },
+ { mode: "graph", label: "图谱", hint: "Neo4j · 实体关系" },
+ { mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
+];
+
+type Results = Partial>;
+
+export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
+ const withDocs = kbs.filter((k) => k.doc_count > 0);
+ const [kbKey, setKbKey] = useState("");
+ const [q, setQ] = useState("");
+ const [topK, setTopK] = useState(5);
+ const [res, setRes] = useState({});
+ const [running, setRunning] = useState(false);
+ const [ran, setRan] = useState(false);
+
+ const run = async () => {
+ if (!kbKey || !q.trim()) return;
+ setRunning(true);
+ setRan(true);
+ // 生产链路 + 四路诊断并行跑,各自独立成败(一路挂不影响其它路展示)。
+ const modes: SearchMode[] = ["", ...ROUTES.map((r) => r.mode)];
+ const settled = await Promise.all(
+ modes.map(async (m) => {
+ try {
+ return [m, { hits: await adminKbSearch(kbKey, q.trim(), topK, m) }] as const;
+ } catch (e) {
+ return [m, { hits: [], err: (e as Error).message }] as const;
+ }
+ }),
+ );
+ setRes(Object.fromEntries(settled));
+ setRunning(false);
+ };
+
+ return (
+
+
+
检索试验台
+ 同一 query 跑通所有检索路,定位召回问题出在哪一环
+
+
+ {/* 查询条件 */}
+
+
+
+
+
+
+
+
+
+
+ {withDocs.length === 0 && (
+
还没有含文档的知识库,先去桌面端建库并入库再来试。
+ )}
+
+ {ran && (
+
+ {/* 生产链路:用户实际拿到的结果 */}
+
+
+ {/* 诊断四路 */}
+
+
+ 分路诊断(分数体系不同,勿跨路比大小)
+
+
+ {ROUTES.map((r) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function RouteCard({
+ label,
+ hint,
+ data,
+ highlight,
+}: {
+ label: string;
+ hint: string;
+ data?: { hits: KbHit[]; err?: string };
+ highlight?: boolean;
+}) {
+ const hits = data?.hits ?? [];
+ return (
+
+
+ {label}
+
+ {hits.length} 命中
+
+
+
{hint}
+
+ {data?.err ? (
+
{data.err}
+ ) : hits.length === 0 ? (
+
这一路没召回
+ ) : (
+
+ {hits.map((h, i) => (
+ -
+
+ #{i + 1}
+ {h.score.toFixed(4)}
+
+
+ {h.text}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/components/UsageSection.tsx
similarity index 95%
rename from sundynix-admin/src/pages/UsagePage.tsx
rename to sundynix-admin/src/components/UsageSection.tsx
index c899caf..fbc30eb 100644
--- a/sundynix-admin/src/pages/UsagePage.tsx
+++ b/sundynix-admin/src/components/UsageSection.tsx
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
-import { GrantCreditsModal } from "../components/GrantCreditsModal";
+import { GrantCreditsModal } from "./GrantCreditsModal";
-// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
-// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
+// 仪表盘的「计费与用量」区块:全平台(或单租户)的积分消耗/Token/成本 + 趋势 + 租户排行。
+// 自包含取数与筛选,直接塞进仪表盘即可,不需要父级传参。
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
const MICRO = 1_000_000;
@@ -37,7 +37,7 @@ function fillDays(trend: UsageDay[], days: number): UsageDay[] {
}
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
-export function UsagePage() {
+export function UsageSection() {
const [days, setDays] = useState(30);
const [tenant, setTenant] = useState(""); // "" = 全平台
const [report, setReport] = useState(null);
@@ -106,11 +106,9 @@ export function UsagePage() {
{/* 发放积分 Modal */}
setGrantTenant(null)} onDone={() => void load()} />
- {/* 本页只做「用量」观测。模型/计价、支付渠道/订单流水均按域收口到「系统配置」页。 */}
-
{/* 观测端:用量结果 */}
-
用量观测
+ 计费与用量
按规则折算后的实际消耗
diff --git a/sundynix-admin/src/index.css b/sundynix-admin/src/index.css
index b9e5d9d..cd4bc99 100644
--- a/sundynix-admin/src/index.css
+++ b/sundynix-admin/src/index.css
@@ -9,6 +9,18 @@ body,
margin: 0;
}
+/* 全局隐藏滚动条(保留滚动功能)——官网与控制台所有页面、以及页内任意滚动区
+ (侧栏、表格 overflow-auto、Modal 等)统一生效。 */
+* {
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* 旧 Edge/IE */
+}
+*::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none; /* Chrome / Safari */
+}
+
/* ══ 官网落地页设计 token(跟随 logo:青→蓝→紫)══
仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。
bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */
diff --git a/sundynix-admin/src/pages/DashboardPage.tsx b/sundynix-admin/src/pages/DashboardPage.tsx
index b5fdcba..fe4f18e 100644
--- a/sundynix-admin/src/pages/DashboardPage.tsx
+++ b/sundynix-admin/src/pages/DashboardPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { UsageSection } from "../components/UsageSection";
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
@@ -150,6 +151,75 @@ export function DashboardPage() {
/>
+ {/* C. 全局任务吞吐 */}
+
+
+
+
+
全平台任务吞吐
+
近 7 天调度中心处理的任务总数(所有租户/用户)
+
+
真实数据
+
+
+
+ {trend.map((d) => (
+ {d.key}
+ ))}
+
+
+
+
+
+
+ {ov.status_count.length === 0 &&
暂无任务
}
+ {ov.status_count
+ .slice()
+ .sort((a, b) => b.count - a.count)
+ .map((d) => {
+ const st = statusStyle(d.key);
+ const pct = (d.count / totalStatus) * 100;
+ return (
+
+
+
+
+ {st.label}
+
+ {d.count} · {pct.toFixed(0)}%
+
+
+
+ );
+ })}
+
+
+
+
+ {/* 计费与用量(原独立页并入:积分消耗/Token/成本 + 趋势 + 各租户排行 + 充值) */}
+
+
{/* B. 控制面配置态(管理端独有) */}
@@ -224,90 +294,6 @@ export function DashboardPage() {
- {/* C. 全局任务吞吐 */}
-
-
-
-
-
全平台任务吞吐
-
近 7 天调度中心处理的任务总数(所有租户/用户)
-
-
真实数据
-
-
-
- {trend.map((d) => (
- {d.key}
- ))}
-
-
-
-
-
-
- {ov.status_count.length === 0 &&
暂无任务
}
- {ov.status_count
- .slice()
- .sort((a, b) => b.count - a.count)
- .map((d) => {
- const st = statusStyle(d.key);
- const pct = (d.count / totalStatus) * 100;
- return (
-
-
-
-
- {st.label}
-
- {d.count} · {pct.toFixed(0)}%
-
-
-
- );
- })}
-
-
-
-
- {/* D. 系统健康拓扑 */}
-
-
-
-
系统健康拓扑
-
基建 + 应用服务 + MCP 工具注册(NATS 实时探活)
-
-
实时
-
-
- ({ name: i.name, up: i.up, detail: i.detail }))} />
- ({ name: s.name, up: s.up, detail: s.detail }))} />
- ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
- />
-
-
);
}
@@ -321,23 +307,6 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
);
}
-function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) {
- return (
-
-
{title}
- {items.map((it) => (
-
-
-
- {it.name}
-
- {it.up ? it.detail || "在线" : "离线"}
-
- ))}
-
- );
-}
-
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
const TONE: Record = {
violet: "bg-violet-50 text-violet-600",
diff --git a/sundynix-admin/src/pages/DatasourcesPage.tsx b/sundynix-admin/src/pages/DatasourcesPage.tsx
index 09bec9b..4a9f09c 100644
--- a/sundynix-admin/src/pages/DatasourcesPage.tsx
+++ b/sundynix-admin/src/pages/DatasourcesPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
+import { RetrievalBench } from "../components/RetrievalBench";
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
@@ -73,10 +74,10 @@ export function DatasourcesPage() {
- 前往「系统配置 → 模型」修改 →
+ 前往「模型配置」修改 →
@@ -240,6 +241,9 @@ export function DatasourcesPage() {
+ {/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */}
+
+
{/* 平台数据源计数 */}
diff --git a/sundynix-admin/src/pages/ModelConfigPage.tsx b/sundynix-admin/src/pages/ModelConfigPage.tsx
new file mode 100644
index 0000000..9a93721
--- /dev/null
+++ b/sundynix-admin/src/pages/ModelConfigPage.tsx
@@ -0,0 +1,20 @@
+import { ModelsPage } from "./ModelsPage";
+import { BillingRules } from "../components/BillingRules";
+
+// 模型配置:模型登记/激活/连通性测试 + 它的计费规则(token→积分汇率、硬拦截、按模型定价与权重)。
+// 配了模型就顺手定它的价,两块放一起。
+// 支付相关(渠道配置 / 订单对账)已拆到「运维 → 支付」下的两个子页,不在这里。
+// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页。
+export function ModelConfigPage() {
+ return (
+
+
+
模型配置
+
对话 / 向量模型登记与激活 · 计费规则与按模型定价
+
+
+
+
+
+ );
+}
diff --git a/sundynix-admin/src/pages/PaymentConfigPage.tsx b/sundynix-admin/src/pages/PaymentConfigPage.tsx
new file mode 100644
index 0000000..7c541b4
--- /dev/null
+++ b/sundynix-admin/src/pages/PaymentConfigPage.tsx
@@ -0,0 +1,15 @@
+import { TopupChannels } from "../components/TopupChannels";
+
+// 支付 · 配置:充值渠道(积分包定价、兑换码生成/台账)+ 微信支付参数。
+// 与「支付 · 订单与对账」拆开:这里只管「怎么收钱」,那边看「收到了什么」。
+export function PaymentConfigPage() {
+ return (
+
+
+
支付 · 配置
+
积分包定价与上下架 · 兑换码生成与台账 · 微信支付商户参数
+
+
+
+ );
+}
diff --git a/sundynix-admin/src/pages/PaymentOrdersPage.tsx b/sundynix-admin/src/pages/PaymentOrdersPage.tsx
new file mode 100644
index 0000000..208c025
--- /dev/null
+++ b/sundynix-admin/src/pages/PaymentOrdersPage.tsx
@@ -0,0 +1,15 @@
+import { OrderStream } from "../components/OrderStream";
+
+// 支付 · 订单与对账:全平台充值订单流 + 状态计数 + 一键对账 + 人工退款。
+// 配置面在「支付 · 配置」,这里只做流水观测与处置。
+export function PaymentOrdersPage() {
+ return (
+
+
+
支付 · 订单与对账
+
充值订单流水 · 账本一致性对账 · 人工退款
+
+
+
+ );
+}
diff --git a/sundynix-admin/src/pages/SettingsPage.tsx b/sundynix-admin/src/pages/SettingsPage.tsx
deleted file mode 100644
index cd39e68..0000000
--- a/sundynix-admin/src/pages/SettingsPage.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useState } from "react";
-import { ModelsPage } from "./ModelsPage";
-import { BillingRules } from "../components/BillingRules";
-import { TopupChannels } from "../components/TopupChannels";
-import { OrderStream } from "../components/OrderStream";
-
-// 系统配置:按「域」聚合 —— 配了什么,就在同一处看它相关的规则与数据。
-// 模型 = 模型登记/激活/连通性测试 + 计费规则(token→积分汇率、硬拦截、按模型定价与权重)
-// 支付 = 支付渠道配置(兑换码 / 积分包 / 微信参数)+ 订单流水(订单流、对账、人工退款)
-// 「计费 & 用量」页只留纯用量观测(消耗趋势/租户排行/发放积分),不再混配置。
-// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页,不塞进 Tab。
-
-type Tab = "models" | "payment";
-
-const TABS: Array<{ key: Tab; label: string; desc: string }> = [
- { key: "models", label: "模型", desc: "模型登记与激活 · 计费规则与按模型定价" },
- { key: "payment", label: "支付", desc: "支付渠道配置 · 订单流水与对账退款" },
-];
-
-export function SettingsPage() {
- const [tab, setTab] = useState
("models");
- const current = TABS.find((t) => t.key === tab);
-
- return (
-
-
-
-
系统配置
-
{current?.desc}
-
-
-
- {TABS.map((t) => (
-
- ))}
-
-
-
- {/* 模型域:先登记模型,紧接着配它的计价规则 */}
- {tab === "models" && (
-
-
-
-
- )}
-
- {/* 支付域:先配渠道,紧接着看这些渠道产生的订单流水与对账 */}
- {tab === "payment" && (
-
-
-
-
- )}
-
- );
-}
diff --git a/sundynix-admin/src/routes.test.tsx b/sundynix-admin/src/routes.test.tsx
index c13cf37..3e0886b 100644
--- a/sundynix-admin/src/routes.test.tsx
+++ b/sundynix-admin/src/routes.test.tsx
@@ -1,11 +1,15 @@
import { describe, it, expect } from "vitest";
-import { routes, navGroups, defaultPath } from "./routes";
+import { routes, navGroups, defaultPath, type NavNode, type RouteDef } from "./routes";
+
+// 把嵌套导航拍平成路由数组(顶层项 + 二级子项),顺序即渲染顺序。
+function flatten(nodes: NavNode[]): RouteDef[] {
+ return nodes.flatMap((n) => (n.kind === "item" ? [n.route] : n.items));
+}
// routes 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。
describe("navGroups", () => {
it("按注册顺序归并分组,每条路由都落到对应组", () => {
- const groups = navGroups();
- const flat = groups.flatMap((g) => g.items);
+ const flat = navGroups().flatMap((g) => flatten(g.nodes));
// 不丢项、不重复
expect(flat).toHaveLength(routes.length);
expect(new Set(flat.map((r) => r.path)).size).toBe(routes.length);
@@ -17,10 +21,31 @@ describe("navGroups", () => {
expect(navGroups().map((g) => g.group)).toEqual(seen);
});
- it("同组路由保持注册时的相对顺序", () => {
+ it("同组路由保持注册时的相对顺序(含二级子项)", () => {
for (const g of navGroups()) {
const expected = routes.filter((r) => r.group === g.group).map((r) => r.path);
- expect(g.items.map((r) => r.path)).toEqual(expected);
+ expect(flatten(g.nodes).map((r) => r.path)).toEqual(expected);
+ }
+ });
+
+ it("带 parent 的路由聚成一个二级菜单,不散落成顶层项", () => {
+ for (const g of navGroups()) {
+ for (const n of g.nodes) {
+ if (n.kind === "item") {
+ expect(n.route.parent).toBeUndefined(); // 顶层项不该带 parent
+ } else {
+ expect(n.items.length).toBeGreaterThan(0);
+ // 同一个二级菜单下的子项,parent 必须都等于该菜单名
+ for (const r of n.items) expect(r.parent).toBe(n.label);
+ }
+ }
+ }
+ });
+
+ it("同 parent 的多条路由只生成一个二级菜单节点", () => {
+ for (const g of navGroups()) {
+ const labels = g.nodes.filter((n) => n.kind === "parent").map((n) => (n as Extract).label);
+ expect(new Set(labels).size).toBe(labels.length);
}
});
diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx
index 82153b6..8bcb26a 100644
--- a/sundynix-admin/src/routes.tsx
+++ b/sundynix-admin/src/routes.tsx
@@ -5,8 +5,7 @@ import { lazy, type ReactNode } from "react";
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
-const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage })));
-const SettingsPage = lazy(() => import("./pages/SettingsPage").then((m) => ({ default: m.SettingsPage })));
+const ModelConfigPage = lazy(() => import("./pages/ModelConfigPage").then((m) => ({ default: m.ModelConfigPage })));
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage })));
@@ -15,52 +14,33 @@ const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ defa
const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage })));
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage })));
+const PaymentConfigPage = lazy(() => import("./pages/PaymentConfigPage").then((m) => ({ default: m.PaymentConfigPage })));
+const PaymentOrdersPage = lazy(() => import("./pages/PaymentOrdersPage").then((m) => ({ default: m.PaymentOrdersPage })));
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage })));
export interface RouteDef {
path: string;
label: string;
group: string;
+ /** 二级菜单名。同 group 内 parent 相同的若干条会聚成一个可展开子菜单(如 运维 > 支付 > 配置/订单与对账)。 */
+ parent?: string;
ready?: boolean;
element: ReactNode;
}
+/** 侧栏一个节点:要么是单条路由,要么是带子项的可展开二级菜单。 */
+export type NavNode =
+ | { kind: "item"; route: RouteDef }
+ | { kind: "parent"; label: string; items: RouteDef[] };
+
export const routes: RouteDef[] = [
{
path: "dashboard",
- label: "概览",
+ label: "仪表盘",
group: "分析",
ready: true,
element: ,
},
- {
- path: "usage",
- label: "计费 & 用量",
- group: "分析",
- ready: true,
- element: ,
- },
- {
- path: "settings",
- label: "系统配置",
- group: "配置",
- ready: true,
- element: ,
- },
- {
- path: "datasources",
- label: "数据源 & RAG",
- group: "配置",
- ready: true,
- element: ,
- },
- {
- path: "prompts",
- label: "提示词",
- group: "配置",
- ready: true,
- element: ,
- },
{
path: "status",
label: "服务状态",
@@ -89,6 +69,43 @@ export const routes: RouteDef[] = [
ready: true,
element: ,
},
+ {
+ path: "models",
+ label: "模型配置",
+ group: "运维",
+ ready: true,
+ element: ,
+ },
+ {
+ path: "datasources",
+ label: "数据源 & RAG",
+ group: "运维",
+ ready: true,
+ element: ,
+ },
+ {
+ path: "prompts",
+ label: "提示词",
+ group: "运维",
+ ready: true,
+ element: ,
+ },
+ {
+ path: "payment/config",
+ label: "配置",
+ group: "运维",
+ parent: "支付",
+ ready: true,
+ element: ,
+ },
+ {
+ path: "payment/orders",
+ label: "订单与对账",
+ group: "运维",
+ parent: "支付",
+ ready: true,
+ element: ,
+ },
{
path: "tenants",
label: "租户 & 用户",
@@ -115,15 +132,22 @@ export const routes: RouteDef[] = [
export const defaultPath = "dashboard";
// 派生分组导航(保持注册顺序)。
-export function navGroups(): Array<{ group: string; items: RouteDef[] }> {
- const out: Array<{ group: string; items: RouteDef[] }> = [];
+export function navGroups(): Array<{ group: string; nodes: NavNode[] }> {
+ const out: Array<{ group: string; nodes: NavNode[] }> = [];
for (const r of routes) {
let g = out.find((x) => x.group === r.group);
if (!g) {
- g = { group: r.group, items: [] };
+ g = { group: r.group, nodes: [] };
out.push(g);
}
- g.items.push(r);
+ if (!r.parent) {
+ g.nodes.push({ kind: "item", route: r });
+ continue;
+ }
+ // 同 parent 的归到同一个可展开节点(首次出现决定它在组内的位置)。
+ const exist = g.nodes.find((n): n is Extract => n.kind === "parent" && n.label === r.parent);
+ if (exist) exist.items.push(r);
+ else g.nodes.push({ kind: "parent", label: r.parent, items: [r] });
}
return out;
}
diff --git a/sundynix-admin/src/shell/AppShell.tsx b/sundynix-admin/src/shell/AppShell.tsx
index 3130338..48adf40 100644
--- a/sundynix-admin/src/shell/AppShell.tsx
+++ b/sundynix-admin/src/shell/AppShell.tsx
@@ -1,7 +1,7 @@
import React, { Suspense, useEffect, useState } from "react";
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
-import { routes, navGroups, defaultPath } from "../routes";
+import { routes, navGroups, defaultPath, type RouteDef } from "../routes";
import { getStatus, type AuthUser } from "../api";
const ADMIN_BASE = "/admin";
@@ -18,7 +18,7 @@ const ICON_MAP: Record = {
),
- settings: (
+ models: (
),
+ "payment/config": (
+
+ ),
+ "payment/orders": (
+
+ ),
+};
+
+// 二级菜单(parent)自己的图标,按 parent 名索引。
+const PARENT_ICON: Record = {
+ 支付: (
+
+ ),
};
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
@@ -130,23 +150,13 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
{navGroups().map((g) => (
{g.group}
- {g.items.map((r) => (
-
- `flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
- isActive
- ? "bg-violet-600 text-white shadow-sm shadow-violet-100"
- : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
- }`
- }
- >
- {ICON_MAP[r.path]}
- {r.label}
- {!r.ready && 规划}
-
- ))}
+ {g.nodes.map((n) =>
+ n.kind === "item" ? (
+
+ ) : (
+
+ ),
+ )}
))}
@@ -211,3 +221,65 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
);
}
+
+// 单条导航项(顶层或二级子项共用)。
+function NavItem({ route: r }: { route: RouteDef }) {
+ return (
+
+ `flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
+ isActive
+ ? "bg-violet-600 text-white shadow-sm shadow-violet-100"
+ : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
+ }`
+ }
+ >
+ {ICON_MAP[r.path]}
+ {r.label}
+ {!r.ready && 规划}
+
+ );
+}
+
+// 可展开的二级菜单(如 运维 > 支付 > 配置 / 订单与对账)。
+// 子项命中时自动展开;父项本身不可导航,只负责收起/展开。
+function NavParent({ label, items }: { label: string; items: RouteDef[] }) {
+ const loc = useLocation();
+ const hasActive = items.some((r) => loc.pathname === `${ADMIN_BASE}/${r.path}`);
+ const [open, setOpen] = useState(hasActive);
+
+ useEffect(() => {
+ if (hasActive) setOpen(true);
+ }, [hasActive]);
+
+ return (
+
+
+ {open && (
+
+ {items.map((r) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go
index 31b65ef..0aadc7f 100644
--- a/sundynix-gateway/internal/handler/kb.go
+++ b/sundynix-gateway/internal/handler/kb.go
@@ -553,6 +553,48 @@ func (h *Handler) KbSearch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"hits": hits})
}
+// AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键**
+// 检索任意租户的知识库(kb 形如 "/",由 /admin/datasources 清单给出)。
+//
+// 与用户侧 KbSearch 的关键差别:这里**不做 scopedKB 改写** —— 用户侧会把库名套上调用者
+// 自己的 space_id,那样 admin 只能搜到自己空间的库,试验台就废了。跨租户是本端点的目的
+// (运维排障:线上召回不准时,定位是 embedding、切块还是融合的问题)。
+//
+// mode: 空=生产混合(含 rerank);vector/fulltext/graph/hybrid=单路/纯融合(不 rerank),
+// 用于逐路对比看是哪一路没召回。
+func (h *Handler) AdminKbSearch(c *gin.Context) {
+ var body struct {
+ KB string `json:"kb"` // 完整作用域键 "/"
+ Q string `json:"q"`
+ TopK int `json:"topK"`
+ Mode string `json:"mode"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.Q == "" || body.KB == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"})
+ return
+ }
+ args := map[string]any{"kb": body.KB, "q": body.Q}
+ if body.TopK > 0 {
+ args["topK"] = body.TopK
+ }
+ if body.Mode != "" {
+ args["mode"] = body.Mode
+ }
+ res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_search"),
+ &contract.ToolCall{Tool: "kb_search", Args: args})
+ if err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+ return
+ }
+ if !res.OK {
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
+ return
+ }
+ var hits []map[string]any
+ _ = json.Unmarshal([]byte(res.Content), &hits)
+ c.JSON(http.StatusOK, gin.H{"hits": hits})
+}
+
// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graph,Neo4j)。
func (h *Handler) KbGraph(c *gin.Context) {
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"),
diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go
index f0a72fc..74d0841 100644
--- a/sundynix-gateway/internal/router/router.go
+++ b/sundynix-gateway/internal/router/router.go
@@ -166,6 +166,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
+ admin.POST("/kb/search", h.AdminKbSearch) // 检索试验台:按完整作用域键跨租户检索(支持单路 mode 对比)
admin.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
diff --git a/sundynix-gateway/internal/store/datasource_query.go b/sundynix-gateway/internal/store/datasource_query.go
index d3b24fc..93ed319 100644
--- a/sundynix-gateway/internal/store/datasource_query.go
+++ b/sundynix-gateway/internal/store/datasource_query.go
@@ -7,8 +7,11 @@ import "context"
// DatasourceKB 是一条知识库的清单行(含文档数与总字数、租户名)。
type DatasourceKB struct {
- ID string `json:"id"`
- Name string `json:"name"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ // SpaceID 是检索作用域键的前半段:向量/全文/图谱三库里的库名是 "/"
+ // (见 handler.scopedKB)。admin 检索试验台要按这个完整键定位库,故必须带出来。
+ SpaceID string `json:"space_id"`
Kind string `json:"kind"`
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
@@ -24,12 +27,12 @@ func (p *Postgres) AllDatasources(ctx context.Context) []DatasourceKB {
}
var out []DatasourceKB
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_kb k").
- Select("k.id, k.name, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+
+ Select("k.id, k.name, k.space_id, k.kind, k.tenant_id, coalesce(t.name,'') as tenant_name, k.owner, "+
"count(d.id) as doc_count, coalesce(sum(d.size),0) as total_words").
Joins("left join sundynix_doc d on d.kb = k.name and d.space_id = k.space_id and d.deleted_at is null").
Joins("left join sundynix_tenant t on t.id = k.tenant_id").
Where("k.deleted_at is null").
- Group("k.id, k.name, k.kind, k.tenant_id, t.name, k.owner").
+ Group("k.id, k.name, k.space_id, k.kind, k.tenant_id, t.name, k.owner").
Order("doc_count desc, k.created_at desc").
Scan(&out)
return out