Merge pull request 'refactor(admin): 配置按域收口到「系统配置」页,Usage 只留用量观测' (#5) from feat/site into main
deploy-132 / deploy (push) Failing after 6s
deploy-132 / deploy (push) Failing after 6s
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -32,6 +32,11 @@ services:
|
|||||||
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
|
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN:-*}
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||||
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
|
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
|
||||||
|
volumes:
|
||||||
|
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
|
||||||
|
# ⚠️ admin「系统配置 → 支付」里填的路径必须是**容器内路径**(/etc/sundynix/wechat-cert/...),
|
||||||
|
# 不是宿主机路径——容器看不到宿主机的 /home/workspace/...。私钥不进镜像、不进 git。
|
||||||
|
- /home/workspace/wechat-pay-cert:/etc/sundynix/wechat-cert:ro
|
||||||
|
|
||||||
dispatcher:
|
dispatcher:
|
||||||
build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile }
|
build: { context: ../.., dockerfile: sundynix-dispatcher/Dockerfile }
|
||||||
|
|||||||
@@ -434,6 +434,8 @@ export async function guardrailEvents(limit = 100): Promise<GuardrailEvent[]> {
|
|||||||
export interface DatasourceKB {
|
export interface DatasourceKB {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 检索作用域键前半段:三库里的库名是 "<space_id>/<name>",检索试验台按此定位。 */
|
||||||
|
space_id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
tenant_name: string;
|
tenant_name: string;
|
||||||
@@ -442,6 +444,30 @@ export interface DatasourceKB {
|
|||||||
total_words: number;
|
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<KbHit[]> {
|
||||||
|
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[] }> {
|
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
|
||||||
const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() }));
|
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 };
|
const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string };
|
||||||
|
|||||||
@@ -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<Record<string, { hits: KbHit[]; err?: string }>>;
|
||||||
|
|
||||||
|
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<Results>({});
|
||||||
|
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 (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">检索试验台</h4>
|
||||||
|
<span className="text-[11px] text-gray-400">同一 query 跑通所有检索路,定位召回问题出在哪一环</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 查询条件 */}
|
||||||
|
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-gray-400">知识库</span>
|
||||||
|
<select
|
||||||
|
value={kbKey}
|
||||||
|
onChange={(e) => setKbKey(e.target.value)}
|
||||||
|
className="w-64 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">选择一个知识库…</option>
|
||||||
|
{withDocs.map((k) => (
|
||||||
|
<option key={k.id} value={`${k.space_id}/${k.name}`}>
|
||||||
|
{k.name} · {k.tenant_name || k.tenant_id} ({k.doc_count} 篇)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-1 flex-col gap-1" style={{ minWidth: 220 }}>
|
||||||
|
<span className="text-[11px] text-gray-400">查询</span>
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && void run()}
|
||||||
|
placeholder="输入一个真实用户会问的问题…"
|
||||||
|
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-gray-400">topK</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={topK}
|
||||||
|
onChange={(e) => setTopK(Math.max(1, Math.min(20, Number(e.target.value) || 5)))}
|
||||||
|
className="w-20 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => void run()}
|
||||||
|
disabled={running || !kbKey || !q.trim()}
|
||||||
|
className="rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{running ? "检索中…" : "跑一遍"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{withDocs.length === 0 && (
|
||||||
|
<p className="mt-3 text-xs text-amber-600">还没有含文档的知识库,先去桌面端建库并入库再来试。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ran && (
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{/* 生产链路:用户实际拿到的结果 */}
|
||||||
|
<RouteCard
|
||||||
|
label="生产链路"
|
||||||
|
hint="混合检索 + rerank —— Agent 实际拿到的就是这个"
|
||||||
|
data={res[""]}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 诊断四路 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
|
||||||
|
分路诊断(分数体系不同,勿跨路比大小)
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{ROUTES.map((r) => (
|
||||||
|
<RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteCard({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
data,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
data?: { hits: KbHit[]; err?: string };
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
const hits = data?.hits ?? [];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border p-3 ${highlight ? "border-violet-200 bg-violet-50/40" : "border-gray-100 bg-gray-50/40"}`}>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-700">{label}</span>
|
||||||
|
<span className={`text-[11px] tabular-nums ${hits.length ? "text-emerald-600" : "text-gray-400"}`}>
|
||||||
|
{hits.length} 命中
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-gray-400">{hint}</div>
|
||||||
|
|
||||||
|
{data?.err ? (
|
||||||
|
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
|
||||||
|
) : hits.length === 0 ? (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-400">这一路没召回</p>
|
||||||
|
) : (
|
||||||
|
<ol className="mt-2 space-y-1.5">
|
||||||
|
{hits.map((h, i) => (
|
||||||
|
<li key={i} className="text-[11px] leading-relaxed">
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="shrink-0 text-gray-300">#{i + 1}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-violet-600">{h.score.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 line-clamp-3 text-gray-600" title={h.text}>
|
||||||
|
{h.text}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-17
@@ -1,13 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||||
import { BillingRules } from "../components/BillingRules";
|
import { GrantCreditsModal } from "./GrantCreditsModal";
|
||||||
import { TopupChannels } from "../components/TopupChannels";
|
|
||||||
import { OrderStream } from "../components/OrderStream";
|
|
||||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
|
||||||
|
|
||||||
|
|
||||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
// 仪表盘的「计费与用量」区块:全平台(或单租户)的积分消耗/Token/成本 + 趋势 + 租户排行。
|
||||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
// 自包含取数与筛选,直接塞进仪表盘即可,不需要父级传参。
|
||||||
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
|
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
|
||||||
|
|
||||||
const MICRO = 1_000_000;
|
const MICRO = 1_000_000;
|
||||||
@@ -40,7 +37,7 @@ function fillDays(trend: UsageDay[], days: number): UsageDay[] {
|
|||||||
}
|
}
|
||||||
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
|
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
|
||||||
|
|
||||||
export function UsagePage() {
|
export function UsageSection() {
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
const [tenant, setTenant] = useState(""); // "" = 全平台
|
const [tenant, setTenant] = useState(""); // "" = 全平台
|
||||||
const [report, setReport] = useState<UsageReport | null>(null);
|
const [report, setReport] = useState<UsageReport | null>(null);
|
||||||
@@ -109,18 +106,9 @@ export function UsagePage() {
|
|||||||
{/* 发放积分 Modal */}
|
{/* 发放积分 Modal */}
|
||||||
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
|
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
|
||||||
|
|
||||||
{/* 配置端:计费规则(改规则即对后续任务生效) */}
|
|
||||||
<BillingRules onSaved={() => void load()} />
|
|
||||||
|
|
||||||
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价 + 微信配置,P5.1/P5.2) */}
|
|
||||||
<TopupChannels />
|
|
||||||
|
|
||||||
{/* 观测端:充值订单流 + 对账(P5.3) */}
|
|
||||||
<OrderStream />
|
|
||||||
|
|
||||||
{/* 观测端:用量结果 */}
|
{/* 观测端:用量结果 */}
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
<h3 className="text-sm font-semibold text-gray-700">计费与用量</h3>
|
||||||
<span className="text-[11px] text-gray-400">按规则折算后的实际消耗</span>
|
<span className="text-[11px] text-gray-400">按规则折算后的实际消耗</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -9,6 +9,18 @@ body,
|
|||||||
margin: 0;
|
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:青→蓝→紫)══
|
/* ══ 官网落地页设计 token(跟随 logo:青→蓝→紫)══
|
||||||
仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。
|
仅 src/site/ 的落地页组件用这些;admin 控制台用默认 gray/violet 调色板,互不影响。
|
||||||
bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */
|
bg-ground/text-ink 等只作用在 SiteLayout 的容器上(见 site-layout.tsx),不改 body。 */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { UsageSection } from "../components/UsageSection";
|
||||||
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
|
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
|
||||||
|
|
||||||
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
|
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
|
||||||
@@ -150,6 +151,75 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* C. 全局任务吞吐 */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">全平台任务吞吐</h4>
|
||||||
|
<p className="text-[11px] text-gray-400">近 7 天调度中心处理的任务总数(所有租户/用户)</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">真实数据</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
|
||||||
|
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
|
||||||
|
{areaPath && <path d={areaPath} fill="url(#g)" />}
|
||||||
|
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
|
||||||
|
{pts.map((p, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
|
||||||
|
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
|
||||||
|
{trend.map((d) => (
|
||||||
|
<span key={d.key}>{d.key}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">任务终态分布</h4>
|
||||||
|
<p className="text-[11px] text-gray-400">近 7 天全局终态占比</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ov.status_count.length === 0 && <div className="text-xs text-gray-400">暂无任务</div>}
|
||||||
|
{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 (
|
||||||
|
<div key={d.key}>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="flex items-center gap-1.5 text-gray-600">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 计费与用量(原独立页并入:积分消耗/Token/成本 + 趋势 + 各租户排行 + 充值) */}
|
||||||
|
<UsageSection />
|
||||||
|
|
||||||
{/* B. 控制面配置态(管理端独有) */}
|
{/* B. 控制面配置态(管理端独有) */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
@@ -224,90 +294,6 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* C. 全局任务吞吐 */}
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700">全平台任务吞吐</h4>
|
|
||||||
<p className="text-[11px] text-gray-400">近 7 天调度中心处理的任务总数(所有租户/用户)</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">真实数据</span>
|
|
||||||
</div>
|
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
|
|
||||||
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
|
|
||||||
{areaPath && <path d={areaPath} fill="url(#g)" />}
|
|
||||||
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
|
|
||||||
{pts.map((p, i) => (
|
|
||||||
<g key={i}>
|
|
||||||
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
|
|
||||||
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
|
|
||||||
{trend.map((d) => (
|
|
||||||
<span key={d.key}>{d.key}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700">任务终态分布</h4>
|
|
||||||
<p className="text-[11px] text-gray-400">近 7 天全局终态占比</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{ov.status_count.length === 0 && <div className="text-xs text-gray-400">暂无任务</div>}
|
|
||||||
{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 (
|
|
||||||
<div key={d.key}>
|
|
||||||
<div className="mb-1 flex items-center justify-between text-xs">
|
|
||||||
<span className="flex items-center gap-1.5 text-gray-600">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
|
||||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* D. 系统健康拓扑 */}
|
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700">系统健康拓扑</h4>
|
|
||||||
<p className="text-[11px] text-gray-400">基建 + 应用服务 + MCP 工具注册(NATS 实时探活)</p>
|
|
||||||
</div>
|
|
||||||
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">实时</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-x-8 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<HealthGroup title="基建" items={status.infra.map((i) => ({ name: i.name, up: i.up, detail: i.detail }))} />
|
|
||||||
<HealthGroup title="应用服务" items={status.services.map((s) => ({ name: s.name, up: s.up, detail: s.detail }))} />
|
|
||||||
<HealthGroup
|
|
||||||
title="MCP 工具组"
|
|
||||||
items={status.tools.map((t) => ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">{title}</div>
|
|
||||||
{items.map((it) => (
|
|
||||||
<div key={it.name} className="flex items-center justify-between border-b border-gray-50 py-1.5 text-xs">
|
|
||||||
<span className="flex items-center gap-1.5 text-gray-600">
|
|
||||||
<span className={`h-2 w-2 rounded-full ${it.up ? "bg-emerald-500" : "bg-rose-500"}`} />
|
|
||||||
{it.name}
|
|
||||||
</span>
|
|
||||||
<span className={`text-[10px] ${it.up ? "text-gray-400" : "text-rose-500"}`}>{it.up ? it.detail || "在线" : "离线"}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
|
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
|
||||||
const TONE: Record<Tone, string> = {
|
const TONE: Record<Tone, string> = {
|
||||||
violet: "bg-violet-50 text-violet-600",
|
violet: "bg-violet-50 text-violet-600",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
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";
|
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
|
||||||
|
|
||||||
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
|
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
|
||||||
@@ -71,16 +73,12 @@ export function DatasourcesPage() {
|
|||||||
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
|
<code className="text-xs font-mono font-medium text-gray-800">{activeEmbedding}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="#/models"
|
to="/admin/models"
|
||||||
onClick={(e) => {
|
className="flex items-center gap-1 text-xs font-medium text-violet-600 hover:text-violet-700 hover:underline"
|
||||||
// 如果使用 hash 路由或 react-router,可正常导航。这里提示用户去模型页配置
|
|
||||||
window.location.hash = "#/models";
|
|
||||||
}}
|
|
||||||
className="text-xs text-violet-600 hover:text-violet-700 font-medium hover:underline flex items-center gap-1"
|
|
||||||
>
|
>
|
||||||
前往模型管理修改 →
|
前往「模型配置」修改 →
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 运维工具 (RAG 迁移) */}
|
{/* 运维工具 (RAG 迁移) */}
|
||||||
@@ -243,6 +241,9 @@ export function DatasourcesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */}
|
||||||
|
<RetrievalBench kbs={rows} />
|
||||||
|
|
||||||
{/* 平台数据源计数 */}
|
{/* 平台数据源计数 */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
|
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ModelsPage } from "./ModelsPage";
|
||||||
|
import { BillingRules } from "../components/BillingRules";
|
||||||
|
|
||||||
|
// 模型配置:模型登记/激活/连通性测试 + 它的计费规则(token→积分汇率、硬拦截、按模型定价与权重)。
|
||||||
|
// 配了模型就顺手定它的价,两块放一起。
|
||||||
|
// 支付相关(渠道配置 / 订单对账)已拆到「运维 → 支付」下的两个子页,不在这里。
|
||||||
|
// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页。
|
||||||
|
export function ModelConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="border-b border-gray-200 pb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800">模型配置</h3>
|
||||||
|
<p className="text-xs text-gray-400">对话 / 向量模型登记与激活 · 计费规则与按模型定价</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelsPage />
|
||||||
|
<BillingRules />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { TopupChannels } from "../components/TopupChannels";
|
||||||
|
|
||||||
|
// 支付 · 配置:充值渠道(积分包定价、兑换码生成/台账)+ 微信支付参数。
|
||||||
|
// 与「支付 · 订单与对账」拆开:这里只管「怎么收钱」,那边看「收到了什么」。
|
||||||
|
export function PaymentConfigPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-gray-200 pb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800">支付 · 配置</h3>
|
||||||
|
<p className="text-xs text-gray-400">积分包定价与上下架 · 兑换码生成与台账 · 微信支付商户参数</p>
|
||||||
|
</div>
|
||||||
|
<TopupChannels />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { OrderStream } from "../components/OrderStream";
|
||||||
|
|
||||||
|
// 支付 · 订单与对账:全平台充值订单流 + 状态计数 + 一键对账 + 人工退款。
|
||||||
|
// 配置面在「支付 · 配置」,这里只做流水观测与处置。
|
||||||
|
export function PaymentOrdersPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-gray-200 pb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800">支付 · 订单与对账</h3>
|
||||||
|
<p className="text-xs text-gray-400">充值订单流水 · 账本一致性对账 · 人工退款</p>
|
||||||
|
</div>
|
||||||
|
<OrderStream />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。
|
// routes 是控制台导航的单一事实源 —— 派生分组必须保序、不丢项、不串组。
|
||||||
describe("navGroups", () => {
|
describe("navGroups", () => {
|
||||||
it("按注册顺序归并分组,每条路由都落到对应组", () => {
|
it("按注册顺序归并分组,每条路由都落到对应组", () => {
|
||||||
const groups = navGroups();
|
const flat = navGroups().flatMap((g) => flatten(g.nodes));
|
||||||
const flat = groups.flatMap((g) => g.items);
|
|
||||||
// 不丢项、不重复
|
// 不丢项、不重复
|
||||||
expect(flat).toHaveLength(routes.length);
|
expect(flat).toHaveLength(routes.length);
|
||||||
expect(new Set(flat.map((r) => r.path)).size).toBe(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);
|
expect(navGroups().map((g) => g.group)).toEqual(seen);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("同组路由保持注册时的相对顺序", () => {
|
it("同组路由保持注册时的相对顺序(含二级子项)", () => {
|
||||||
for (const g of navGroups()) {
|
for (const g of navGroups()) {
|
||||||
const expected = routes.filter((r) => r.group === g.group).map((r) => r.path);
|
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<NavNode, { kind: "parent" }>).label);
|
||||||
|
expect(new Set(labels).size).toBe(labels.length);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { lazy, type ReactNode } from "react";
|
|||||||
|
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
|
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
|
||||||
const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage })));
|
const ModelConfigPage = lazy(() => import("./pages/ModelConfigPage").then((m) => ({ default: m.ModelConfigPage })));
|
||||||
const ModelsPage = lazy(() => import("./pages/ModelsPage").then((m) => ({ default: m.ModelsPage })));
|
|
||||||
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
|
const DatasourcesPage = lazy(() => import("./pages/DatasourcesPage").then((m) => ({ default: m.DatasourcesPage })));
|
||||||
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
|
const StatusPage = lazy(() => import("./pages/StatusPage").then((m) => ({ default: m.StatusPage })));
|
||||||
const TasksPage = lazy(() => import("./pages/TasksPage").then((m) => ({ default: m.TasksPage })));
|
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 GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage })));
|
||||||
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
|
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
|
||||||
const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage })));
|
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 })));
|
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((m) => ({ default: m.SpacesPage })));
|
||||||
|
|
||||||
export interface RouteDef {
|
export interface RouteDef {
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
|
/** 二级菜单名。同 group 内 parent 相同的若干条会聚成一个可展开子菜单(如 运维 > 支付 > 配置/订单与对账)。 */
|
||||||
|
parent?: string;
|
||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
element: ReactNode;
|
element: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 侧栏一个节点:要么是单条路由,要么是带子项的可展开二级菜单。 */
|
||||||
|
export type NavNode =
|
||||||
|
| { kind: "item"; route: RouteDef }
|
||||||
|
| { kind: "parent"; label: string; items: RouteDef[] };
|
||||||
|
|
||||||
export const routes: RouteDef[] = [
|
export const routes: RouteDef[] = [
|
||||||
{
|
{
|
||||||
path: "dashboard",
|
path: "dashboard",
|
||||||
label: "概览",
|
label: "仪表盘",
|
||||||
group: "分析",
|
group: "分析",
|
||||||
ready: true,
|
ready: true,
|
||||||
element: <DashboardPage />,
|
element: <DashboardPage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "usage",
|
|
||||||
label: "计费 & 用量",
|
|
||||||
group: "分析",
|
|
||||||
ready: true,
|
|
||||||
element: <UsagePage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "models",
|
|
||||||
label: "模型",
|
|
||||||
group: "配置",
|
|
||||||
ready: true,
|
|
||||||
element: <ModelsPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "datasources",
|
|
||||||
label: "数据源 & RAG",
|
|
||||||
group: "配置",
|
|
||||||
ready: true,
|
|
||||||
element: <DatasourcesPage />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "prompts",
|
|
||||||
label: "提示词",
|
|
||||||
group: "配置",
|
|
||||||
ready: true,
|
|
||||||
element: <PromptsPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "status",
|
path: "status",
|
||||||
label: "服务状态",
|
label: "服务状态",
|
||||||
@@ -89,6 +69,43 @@ export const routes: RouteDef[] = [
|
|||||||
ready: true,
|
ready: true,
|
||||||
element: <AuditPage />,
|
element: <AuditPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "models",
|
||||||
|
label: "模型配置",
|
||||||
|
group: "运维",
|
||||||
|
ready: true,
|
||||||
|
element: <ModelConfigPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "datasources",
|
||||||
|
label: "数据源 & RAG",
|
||||||
|
group: "运维",
|
||||||
|
ready: true,
|
||||||
|
element: <DatasourcesPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "prompts",
|
||||||
|
label: "提示词",
|
||||||
|
group: "运维",
|
||||||
|
ready: true,
|
||||||
|
element: <PromptsPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "payment/config",
|
||||||
|
label: "配置",
|
||||||
|
group: "运维",
|
||||||
|
parent: "支付",
|
||||||
|
ready: true,
|
||||||
|
element: <PaymentConfigPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "payment/orders",
|
||||||
|
label: "订单与对账",
|
||||||
|
group: "运维",
|
||||||
|
parent: "支付",
|
||||||
|
ready: true,
|
||||||
|
element: <PaymentOrdersPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "tenants",
|
path: "tenants",
|
||||||
label: "租户 & 用户",
|
label: "租户 & 用户",
|
||||||
@@ -115,15 +132,22 @@ export const routes: RouteDef[] = [
|
|||||||
export const defaultPath = "dashboard";
|
export const defaultPath = "dashboard";
|
||||||
|
|
||||||
// 派生分组导航(保持注册顺序)。
|
// 派生分组导航(保持注册顺序)。
|
||||||
export function navGroups(): Array<{ group: string; items: RouteDef[] }> {
|
export function navGroups(): Array<{ group: string; nodes: NavNode[] }> {
|
||||||
const out: Array<{ group: string; items: RouteDef[] }> = [];
|
const out: Array<{ group: string; nodes: NavNode[] }> = [];
|
||||||
for (const r of routes) {
|
for (const r of routes) {
|
||||||
let g = out.find((x) => x.group === r.group);
|
let g = out.find((x) => x.group === r.group);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
g = { group: r.group, items: [] };
|
g = { group: r.group, nodes: [] };
|
||||||
out.push(g);
|
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<NavNode, { kind: "parent" }> => 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { Suspense, useEffect, useState } from "react";
|
import React, { Suspense, useEffect, useState } from "react";
|
||||||
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
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";
|
import { getStatus, type AuthUser } from "../api";
|
||||||
|
|
||||||
const ADMIN_BASE = "/admin";
|
const ADMIN_BASE = "/admin";
|
||||||
@@ -20,7 +20,8 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
),
|
),
|
||||||
models: (
|
models: (
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
<path strokeLinecap="round" strokeLinejoin="round" 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" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
datasources: (
|
datasources: (
|
||||||
@@ -68,6 +69,26 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
"payment/config": (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" 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" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
"payment/orders": (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 14l2 2 4-4M7 21h10a2 2 0 002-2V7l-4-4H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 二级菜单(parent)自己的图标,按 parent 名索引。
|
||||||
|
const PARENT_ICON: Record<string, React.ReactNode> = {
|
||||||
|
支付: (
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||||
@@ -129,23 +150,13 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
|
|||||||
{navGroups().map((g) => (
|
{navGroups().map((g) => (
|
||||||
<div key={g.group} className="space-y-1">
|
<div key={g.group} className="space-y-1">
|
||||||
<div className="px-3 py-1 text-[9px] font-bold uppercase tracking-wider text-gray-400">{g.group}</div>
|
<div className="px-3 py-1 text-[9px] font-bold uppercase tracking-wider text-gray-400">{g.group}</div>
|
||||||
{g.items.map((r) => (
|
{g.nodes.map((n) =>
|
||||||
<NavLink
|
n.kind === "item" ? (
|
||||||
key={r.path}
|
<NavItem key={n.route.path} route={n.route} />
|
||||||
to={`${ADMIN_BASE}/${r.path}`}
|
) : (
|
||||||
className={({ isActive }) =>
|
<NavParent key={n.label} label={n.label} items={n.items} />
|
||||||
`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"
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="shrink-0">{ICON_MAP[r.path]}</span>
|
|
||||||
<span className="flex-1 truncate">{r.label}</span>
|
|
||||||
{!r.ready && <span className="bg-gray-100 text-gray-400 px-1 py-0.2 rounded text-[8px]">规划</span>}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -210,3 +221,65 @@ export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 单条导航项(顶层或二级子项共用)。
|
||||||
|
function NavItem({ route: r }: { route: RouteDef }) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={`${ADMIN_BASE}/${r.path}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`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"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{ICON_MAP[r.path]}</span>
|
||||||
|
<span className="flex-1 truncate">{r.label}</span>
|
||||||
|
{!r.ready && <span className="rounded bg-gray-100 px-1 text-[8px] text-gray-400">规划</span>}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可展开的二级菜单(如 运维 > 支付 > 配置 / 订单与对账)。
|
||||||
|
// 子项命中时自动展开;父项本身不可导航,只负责收起/展开。
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-xs font-semibold transition-all ${
|
||||||
|
hasActive ? "text-violet-700" : "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{PARENT_ICON[label]}</span>
|
||||||
|
<span className="flex-1 truncate text-left">{label}</span>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 shrink-0 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="ml-4 mt-1 space-y-1 border-l border-gray-200 pl-2">
|
||||||
|
{items.map((r) => (
|
||||||
|
<NavItem key={r.path} route={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -553,6 +553,48 @@ func (h *Handler) KbSearch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"hits": hits})
|
c.JSON(http.StatusOK, gin.H{"hits": hits})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键**
|
||||||
|
// 检索任意租户的知识库(kb 形如 "<space_id>/<name>",由 /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"` // 完整作用域键 "<space_id>/<name>"
|
||||||
|
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)。
|
// KbGraph: GET /api/v1/kb/graph?kb= —— 某知识库的图谱三元组(→ mcp-go kb_graph,Neo4j)。
|
||||||
func (h *Handler) KbGraph(c *gin.Context) {
|
func (h *Handler) KbGraph(c *gin.Context) {
|
||||||
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"),
|
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("kb_graph"),
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||||
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
|
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
|
||||||
admin.GET("/datasources", h.AdminDatasources) // 数据源清单:全平台知识库 + 文档数(真数据)
|
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.POST("/migrate-kb-storage", h.MigrateKBStorage) // 增量3:存量 KB 三库 owner/kb→space/kb 重灌(一次性)
|
||||||
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
||||||
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
|
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import "context"
|
|||||||
|
|
||||||
// DatasourceKB 是一条知识库的清单行(含文档数与总字数、租户名)。
|
// DatasourceKB 是一条知识库的清单行(含文档数与总字数、租户名)。
|
||||||
type DatasourceKB struct {
|
type DatasourceKB struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
// SpaceID 是检索作用域键的前半段:向量/全文/图谱三库里的库名是 "<space_id>/<name>"
|
||||||
|
// (见 handler.scopedKB)。admin 检索试验台要按这个完整键定位库,故必须带出来。
|
||||||
|
SpaceID string `json:"space_id"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
TenantName string `json:"tenant_name"`
|
TenantName string `json:"tenant_name"`
|
||||||
@@ -24,12 +27,12 @@ func (p *Postgres) AllDatasources(ctx context.Context) []DatasourceKB {
|
|||||||
}
|
}
|
||||||
var out []DatasourceKB
|
var out []DatasourceKB
|
||||||
p.db.WithContext(WithoutTenant(ctx)).Table("sundynix_kb k").
|
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").
|
"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_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").
|
Joins("left join sundynix_tenant t on t.id = k.tenant_id").
|
||||||
Where("k.deleted_at is null").
|
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").
|
Order("doc_count desc, k.created_at desc").
|
||||||
Scan(&out)
|
Scan(&out)
|
||||||
return out
|
return out
|
||||||
|
|||||||
Reference in New Issue
Block a user