feat: 新增技能地址管理与开放问答接口,完善任务工作区
- 新增「技能地址管理」页面与 /api/skill-addresses 路由(扫描/预览/批量替换技能内地址) - 完善 /api/open/chat 开放问答接口与模型工厂、网关配置 - 调整任务工作区、侧边栏与路由 - 将本地 0622/ 临时目录与 __pycache__/ 加入忽略,不提交 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
32476c7ad4
commit
6b480fdbe9
15 files changed
+1749
-40
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
Replace,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
User,
|
||||
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/skill-addresses")}
|
||||
>
|
||||
<Replace className="size-4" />
|
||||
技能地址管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/skill-addresses")}>
|
||||
<Replace className="size-4" />
|
||||
技能地址管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||||
<ShieldAlert className="size-4" />
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
FileCode,
|
||||
Globe,
|
||||
Info,
|
||||
Loader2,
|
||||
Network,
|
||||
Replace,
|
||||
ScanLine,
|
||||
Search,
|
||||
Undo2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type AddressGroup,
|
||||
type AddressKind,
|
||||
type ApplyProgress,
|
||||
type PreviewResult,
|
||||
type Replacement,
|
||||
type ScanProgress,
|
||||
type ScanResult,
|
||||
applyReplacementsStream,
|
||||
previewReplacements,
|
||||
rollbackBackup,
|
||||
scanAddressesStream,
|
||||
useSkillAddressBackups,
|
||||
} from "@/strategy-components/api/skill-addresses";
|
||||
|
||||
const ALL_KINDS: AddressKind[] = ["url", "ip"];
|
||||
|
||||
function ProgressBar({ done, total, label }: { done: number; total: number; label: string }) {
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-200/70 bg-blue-50/60 p-3.5">
|
||||
<div className="mb-1.5 flex items-center justify-between text-xs font-medium text-blue-700">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{done} / {total}({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-blue-100">
|
||||
<div className="h-full rounded-full bg-blue-500 transition-all duration-200" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KindBadge({ kind }: { kind: AddressKind }) {
|
||||
const isUrl = kind === "url";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold ${
|
||||
isUrl ? "bg-indigo-50 text-indigo-600" : "bg-emerald-50 text-emerald-600"
|
||||
}`}
|
||||
>
|
||||
{isUrl ? <Globe className="size-3" /> : <Network className="size-3" />}
|
||||
{isUrl ? "URL" : "IP"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillAddressesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
|
||||
const [kinds, setKinds] = useState<AddressKind[]>(ALL_KINDS);
|
||||
const [scan, setScan] = useState<ScanResult | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanProg, setScanProg] = useState<ScanProgress | null>(null);
|
||||
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyProg, setApplyProg] = useState<ApplyProgress | null>(null);
|
||||
const [lastBackupId, setLastBackupId] = useState<string | null>(null);
|
||||
|
||||
const [segFind, setSegFind] = useState("");
|
||||
const [segRepl, setSegRepl] = useState("");
|
||||
|
||||
const { data: backups = [], refetch: refetchBackups } = useSkillAddressBackups();
|
||||
|
||||
const runScan = useCallback(
|
||||
async (useKinds: AddressKind[]) => {
|
||||
setScanning(true);
|
||||
setScanProg({ done: 0, total: 0 });
|
||||
setPreview(null);
|
||||
try {
|
||||
const result = await scanAddressesStream({ kinds: useKinds, onProgress: setScanProg });
|
||||
setScan(result);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "扫描失败");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setScanProg(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 首次进入自动扫描。
|
||||
useEffect(() => {
|
||||
if (isAdmin) void runScan(ALL_KINDS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdmin]);
|
||||
|
||||
const groups = scan?.groups ?? [];
|
||||
|
||||
const toggleKind = (k: AddressKind) => {
|
||||
const next = kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k];
|
||||
if (next.length === 0) return; // 至少保留一种
|
||||
setKinds(next);
|
||||
void runScan(next);
|
||||
};
|
||||
|
||||
const setEdit = (addr: string, val: string) => setEdits((p) => ({ ...p, [addr]: val }));
|
||||
|
||||
const toggleExpand = (addr: string) =>
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(addr)) n.delete(addr);
|
||||
else n.add(addr);
|
||||
return n;
|
||||
});
|
||||
|
||||
/** 片段批量套用:把所有地址里出现的「查找片段」整体替换为「替换片段」,填入各行的「替换为」。 */
|
||||
const applySegment = () => {
|
||||
const find = segFind.trim();
|
||||
if (!find) {
|
||||
toast.error("请先填写要查找的片段(如 uat.4.cn)");
|
||||
return;
|
||||
}
|
||||
let n = 0;
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const g of groups) {
|
||||
if (g.address.includes(find)) {
|
||||
const replaced = g.address.split(find).join(segRepl);
|
||||
if (replaced !== g.address) {
|
||||
next[g.address] = replaced;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (n === 0) toast.info(`没有地址包含片段「${find}」`);
|
||||
else toast.success(`已为 ${n} 个地址套用替换(请预览后应用)`);
|
||||
};
|
||||
|
||||
const buildReplacements = useCallback((): Replacement[] => {
|
||||
const out: Replacement[] = [];
|
||||
for (const g of groups) {
|
||||
const to = (edits[g.address] ?? "").trim();
|
||||
if (to && to !== g.address) out.push({ from: g.address, to, kind: g.kind });
|
||||
}
|
||||
return out;
|
||||
}, [groups, edits]);
|
||||
|
||||
const pendingCount = useMemo(() => buildReplacements().length, [buildReplacements]);
|
||||
|
||||
const runPreview = async () => {
|
||||
const repls = buildReplacements();
|
||||
if (repls.length === 0) {
|
||||
toast.error("还没有填写任何「替换为」");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await previewReplacements(repls);
|
||||
setPreview(result);
|
||||
if (result.total === 0) toast.info("没有任何文件会被改动(请检查填写的替换值)");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "预览失败");
|
||||
}
|
||||
};
|
||||
|
||||
const runApply = async () => {
|
||||
if (!preview) return;
|
||||
const repls = buildReplacements();
|
||||
if (repls.length === 0) return;
|
||||
setApplying(true);
|
||||
setApplyProg({ done: 0, totalFiles: 0 });
|
||||
try {
|
||||
const result = await applyReplacementsStream(repls, {
|
||||
expectedTotal: preview.expectedTotal,
|
||||
backup: true,
|
||||
onProgress: setApplyProg,
|
||||
});
|
||||
toast.success(`已修改 ${result.changedFiles.length} 个文件,共替换 ${result.replacedTotal} 处`);
|
||||
setLastBackupId(result.backupId);
|
||||
setEdits({});
|
||||
setPreview(null);
|
||||
await refetchBackups();
|
||||
await runScan(kinds);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "应用失败");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
setApplyProg(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runRollback = async () => {
|
||||
const id = lastBackupId ?? backups[0]?.backupId;
|
||||
if (!id) {
|
||||
toast.error("没有可撤销的备份");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { restored } = await rollbackBackup(id);
|
||||
toast.success(`已从备份恢复 ${restored} 个文件`);
|
||||
setLastBackupId(null);
|
||||
await refetchBackups();
|
||||
await runScan(kinds);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "撤销失败");
|
||||
}
|
||||
};
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const visible = useMemo(
|
||||
() => (!q ? groups : groups.filter((g) => g.address.toLowerCase().includes(q) || g.occurrences.some((o) => o.skill.toLowerCase().includes(q)))),
|
||||
[groups, q],
|
||||
);
|
||||
|
||||
const totalOccurrences = useMemo(() => groups.reduce((s, g) => s + g.count, 0), [groups]);
|
||||
const canRollback = !!(lastBackupId || backups.length > 0);
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* 顶部信息卡 */}
|
||||
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
|
||||
<Replace className="size-44 text-slate-900" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-600">
|
||||
<Wand2 className="size-3" />
|
||||
技能维护工具
|
||||
</span>
|
||||
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
|
||||
技能地址管理
|
||||
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
|
||||
{groups.length} 地址 / {totalOccurrences} 处
|
||||
{scan ? ` / ${scan.scannedSkills} 技能` : ""}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runRollback}
|
||||
disabled={!canRollback || scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="从最近一次的磁盘备份恢复"
|
||||
>
|
||||
<Undo2 className="size-[15px]" />
|
||||
撤销上次
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runScan(kinds)}
|
||||
disabled={scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-blue-600 bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{scanning ? <Loader2 className="size-[15px] animate-spin" /> : <ScanLine className="size-[15px]" />}
|
||||
{scanning ? "扫描中…" : "重新扫描"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex max-w-3xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
扫描运行中的技能(<code className="rounded bg-slate-100 px-1">skills/public</code> 与{" "}
|
||||
<code className="rounded bg-slate-100 px-1">skills/custom</code> 下所有 .md / .py),按<strong>唯一地址</strong>聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后<strong>预览</strong>命中再<strong>应用</strong>;应用前会自动磁盘备份,可一键<strong>撤销</strong>。替换为精确整串匹配,不会误伤子串。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{scanning && scanProg && (
|
||||
<div className="mb-6">
|
||||
<ProgressBar done={scanProg.done} total={scanProg.total} label={`正在扫描技能${scanProg.skill ? `:${scanProg.skill}` : "…"}`} />
|
||||
</div>
|
||||
)}
|
||||
{applying && applyProg && (
|
||||
<div className="mb-6">
|
||||
<ProgressBar done={applyProg.done} total={applyProg.totalFiles} label={`正在修改文件${applyProg.file ? `:${applyProg.file}` : "…"}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏:类型过滤 + 搜索 + 片段批量套用 */}
|
||||
<div className="mb-6 space-y-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{ALL_KINDS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => toggleKind(k)}
|
||||
disabled={scanning || applying}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
kinds.includes(k)
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{k === "url" ? <Globe className="size-3.5" /> : <Network className="size-3.5" />}
|
||||
{k === "url" ? "URL" : "IP"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索地址 / 技能名…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-500/5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 片段批量套用 */}
|
||||
<div className="flex flex-col items-stretch gap-2.5 rounded-xl border border-dashed border-slate-200 bg-slate-50/50 p-3 md:flex-row md:items-center">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
|
||||
<Wand2 className="size-3.5" />
|
||||
片段批量套用
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="查找片段,如 uat.4.cn"
|
||||
value={segFind}
|
||||
onChange={(e) => setSegFind(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10"
|
||||
/>
|
||||
<span className="shrink-0 text-slate-400">→</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="替换为,如 prod.4.cn"
|
||||
value={segRepl}
|
||||
onChange={(e) => setSegRepl(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySegment}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3.5 py-1.5 text-sm font-semibold text-slate-700 transition-all hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
套用到匹配项
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览面板 */}
|
||||
{preview && (
|
||||
<div className="mb-6 overflow-hidden rounded-2xl border border-blue-200/70 bg-white shadow-sm">
|
||||
<div className="flex flex-col items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/50 px-4 py-3 md:flex-row md:items-center">
|
||||
<div className="text-sm font-semibold text-slate-700">
|
||||
预览:将改动 <span className="font-mono text-blue-700">{preview.total}</span> 处
|
||||
({preview.items.filter((i) => i.count > 0).length} 个地址命中)
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runApply}
|
||||
disabled={applying || preview.total === 0}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-blue-600 bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{applying ? <Loader2 className="size-[15px] animate-spin" /> : <Replace className="size-[15px]" />}
|
||||
{applying ? "应用中…" : "确认应用"}
|
||||
</button>
|
||||
</div>
|
||||
{preview.warnings.length > 0 && (
|
||||
<div className="border-b border-amber-100 bg-amber-50/60 px-4 py-2.5">
|
||||
{preview.warnings.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-700">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ul className="max-h-72 divide-y divide-slate-100 overflow-auto">
|
||||
{preview.items.map((it) => (
|
||||
<li key={it.from} className="px-4 py-2.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<KindBadge kind={it.kind} />
|
||||
<span className="font-mono text-slate-500 line-through">{it.from}</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span className="font-mono font-medium text-blue-700">{it.to}</span>
|
||||
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{it.count} 处</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 地址列表 */}
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
|
||||
<span>地址({visible.length})</span>
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPreview}
|
||||
disabled={scanning || applying}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-300 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 transition-all hover:bg-blue-100 disabled:opacity-50"
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
预览改动({pendingCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanning && !scan ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">扫描中…</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{groups.length === 0 ? "未发现任何 URL / IP 地址" : "没有匹配的地址"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{visible.map((g) => (
|
||||
<GroupRow
|
||||
key={g.address}
|
||||
group={g}
|
||||
value={edits[g.address] ?? ""}
|
||||
expanded={expanded.has(g.address)}
|
||||
onToggle={() => toggleExpand(g.address)}
|
||||
onChange={(v) => setEdit(g.address, v)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({
|
||||
group,
|
||||
value,
|
||||
expanded,
|
||||
onToggle,
|
||||
onChange,
|
||||
}: {
|
||||
group: AddressGroup;
|
||||
value: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const dirty = value.trim() !== "" && value.trim() !== group.address;
|
||||
return (
|
||||
<li className={`px-4 py-3 transition-colors hover:bg-slate-50/60 ${dirty ? "bg-blue-50/30" : ""}`}>
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button type="button" onClick={onToggle} className="flex size-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
||||
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
<KindBadge kind={group.kind} />
|
||||
<span className="truncate font-mono text-sm text-slate-700" title={group.address}>
|
||||
{group.address}
|
||||
</span>
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">{group.count} 处</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="替换为(留空 = 不改)"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 font-mono text-sm transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/10 lg:w-96"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<ul className="mt-2.5 ml-8 space-y-1 border-l border-slate-100 pl-3">
|
||||
{group.occurrences.map((o, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs text-slate-500">
|
||||
<FileCode className="mt-0.5 size-3.5 shrink-0 text-slate-400" />
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-slate-600">{o.skill}</span>
|
||||
<span className="mx-1 text-slate-300">·</span>
|
||||
<span className="font-mono">
|
||||
{o.file}:{o.line}
|
||||
</span>
|
||||
<div className="truncate font-mono text-slate-400" title={o.snippet}>
|
||||
{o.snippet}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
@@ -393,6 +394,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skill-addresses"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<SkillAddressesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 技能地址(URL/IP)批量替换 API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/skill-addresses`(见 app/gateway/routers/skill_addresses.py)。
|
||||
* 全部仅管理员可用。扫描/应用提供 **NDJSON 流式进度**端点(150+ 技能时显示
|
||||
* 「已处理 X / 共 N」进度条),并保留一次性端点作为兜底。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`;
|
||||
|
||||
export type AddressKind = "url" | "ip";
|
||||
|
||||
export interface AddressOccurrence {
|
||||
skill: string;
|
||||
category: string; // "public" | "custom"
|
||||
file: string; // 相对技能根的 POSIX 路径
|
||||
line: number;
|
||||
col: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface AddressGroup {
|
||||
address: string;
|
||||
kind: AddressKind;
|
||||
count: number;
|
||||
occurrences: AddressOccurrence[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
groups: AddressGroup[];
|
||||
scannedFiles: number;
|
||||
scannedSkills: number;
|
||||
kinds: string[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
export interface Replacement {
|
||||
from: string;
|
||||
to: string;
|
||||
kind?: AddressKind;
|
||||
}
|
||||
|
||||
export interface PreviewFileHit {
|
||||
file: string;
|
||||
line: number;
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
export interface PreviewItem {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: AddressKind;
|
||||
count: number;
|
||||
files: PreviewFileHit[];
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
items: PreviewItem[];
|
||||
total: number;
|
||||
warnings: string[];
|
||||
expectedTotal: number;
|
||||
}
|
||||
|
||||
export interface ChangedFile {
|
||||
file: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
changedFiles: ChangedFile[];
|
||||
replacedTotal: number;
|
||||
backupId: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
backupId: string;
|
||||
files: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 扫描进度(逐技能)。 */
|
||||
export interface ScanProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
/** 应用进度(逐文件)。 */
|
||||
export interface ApplyProgress {
|
||||
done: number;
|
||||
totalFiles: number;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
// ── 错误处理 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function ensureOk(res: Response, ctx: string): Promise<void> {
|
||||
if (res.ok) return;
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} 失败 (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
await ensureOk(res, ctx);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── 序列化辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function toScanResult(d: any): ScanResult {
|
||||
return {
|
||||
groups: (d.groups ?? []) as AddressGroup[],
|
||||
scannedFiles: d.scanned_files ?? 0,
|
||||
scannedSkills: d.scanned_skills ?? 0,
|
||||
kinds: d.kinds ?? [],
|
||||
scannedAt: d.scanned_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toPreviewResult(d: any): PreviewResult {
|
||||
return {
|
||||
items: (d.items ?? []).map((i: any) => ({
|
||||
from: i.from,
|
||||
to: i.to,
|
||||
kind: i.kind,
|
||||
count: i.count ?? 0,
|
||||
files: i.files ?? [],
|
||||
})),
|
||||
total: d.total ?? 0,
|
||||
warnings: d.warnings ?? [],
|
||||
expectedTotal: d.expected_total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toApplyResult(d: any): ApplyResult {
|
||||
return {
|
||||
changedFiles: (d.changed_files ?? []).map((c: any) => ({ file: c.file, count: c.count })),
|
||||
replacedTotal: d.replaced_total ?? 0,
|
||||
backupId: d.backup_id ?? null,
|
||||
warnings: d.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── NDJSON 流式读取 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 逐行解析 NDJSON 流,对每个事件对象调用 onEvent。 */
|
||||
async function readNdjson(res: Response, ctx: string, onEvent: (ev: any) => void): Promise<void> {
|
||||
await ensureOk(res, ctx);
|
||||
if (!res.body) throw new Error(`${ctx}:响应无数据流`);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const flush = (chunk: string) => {
|
||||
buf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (line) onEvent(JSON.parse(line));
|
||||
}
|
||||
};
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
flush(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
const tail = (buf + decoder.decode()).trim();
|
||||
if (tail) onEvent(JSON.parse(tail));
|
||||
}
|
||||
|
||||
// ── 端点 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 一次性扫描(兜底,无进度)。 */
|
||||
export async function scanAddresses(kinds?: AddressKind[], skill?: string): Promise<ScanResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (kinds && kinds.length) params.set("kinds", kinds.join(","));
|
||||
if (skill) params.set("skill", skill);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" });
|
||||
return toScanResult(await readJson<any>(res, "扫描"));
|
||||
}
|
||||
|
||||
/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */
|
||||
export async function scanAddressesStream(
|
||||
opts: { kinds?: AddressKind[]; skill?: string; onProgress?: (p: ScanProgress) => void } = {},
|
||||
): Promise<ScanResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.kinds && opts.kinds.length) params.set("kinds", opts.kinds.join(","));
|
||||
if (opts.skill) params.set("skill", opts.skill);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${BASE()}/scan/stream${qs ? `?${qs}` : ""}`, { method: "GET" });
|
||||
let result: ScanResult | null = null;
|
||||
await readNdjson(res, "扫描", (ev) => {
|
||||
if (ev.type === "start") opts.onProgress?.({ done: 0, total: ev.total ?? 0 });
|
||||
else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, total: ev.total, skill: ev.skill });
|
||||
else if (ev.type === "result") result = toScanResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("扫描未返回结果");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 干跑预览。 */
|
||||
export async function previewReplacements(replacements: Replacement[]): Promise<PreviewResult> {
|
||||
const res = await apiFetch(`${BASE()}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ replacements }),
|
||||
});
|
||||
return toPreviewResult(await readJson<any>(res, "预览"));
|
||||
}
|
||||
|
||||
/** 流式应用:逐文件上报进度,返回最终结果。`expectedTotal` 做乐观锁。 */
|
||||
export async function applyReplacementsStream(
|
||||
replacements: Replacement[],
|
||||
opts: { expectedTotal?: number; backup?: boolean; onProgress?: (p: ApplyProgress) => void } = {},
|
||||
): Promise<ApplyResult> {
|
||||
const body = {
|
||||
replacements,
|
||||
expected_total: opts.expectedTotal ?? null,
|
||||
backup: opts.backup ?? true,
|
||||
};
|
||||
const res = await apiFetch(`${BASE()}/apply/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let result: ApplyResult | null = null;
|
||||
await readNdjson(res, "应用", (ev) => {
|
||||
if (ev.type === "start") opts.onProgress?.({ done: 0, totalFiles: ev.total_files ?? 0 });
|
||||
else if (ev.type === "progress") opts.onProgress?.({ done: ev.done, totalFiles: ev.total_files, file: ev.file });
|
||||
else if (ev.type === "result") result = toApplyResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("应用未返回结果");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listBackups(): Promise<BackupEntry[]> {
|
||||
const res = await apiFetch(`${BASE()}/backups`, { method: "GET" });
|
||||
const data = await readJson<{ backups: any[] }>(res, "读取备份");
|
||||
return (data.backups ?? []).map((b) => ({ backupId: b.backup_id, files: b.files ?? 0, createdAt: b.created_at ?? "" }));
|
||||
}
|
||||
|
||||
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
|
||||
const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" });
|
||||
const data = await readJson<{ restored_files: string[] }>(res, "撤销");
|
||||
return { restored: (data.restored_files ?? []).length };
|
||||
}
|
||||
|
||||
// ── React Query ────────────────────────────────────────────────────────────
|
||||
|
||||
export const SKILL_ADDRESS_BACKUPS_KEY = "skill-address-backups";
|
||||
|
||||
export function useSkillAddressBackups() {
|
||||
return useQuery({
|
||||
queryKey: [SKILL_ADDRESS_BACKUPS_KEY],
|
||||
queryFn: listBackups,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -343,7 +343,10 @@ function RwfxSqReportGate({ taskId }: { taskId: string }) {
|
||||
暂不
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-[var(--color-main)] text-white hover:bg-[var(--brand-950)]"
|
||||
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故不能用
|
||||
// 局部的 `--color-main`(红主题侥幸有值、蓝主题失效→按钮消失)。改用全局按主题
|
||||
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
|
||||
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
|
||||
onClick={handleGo3qfx}
|
||||
disabled={jumping}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user