feat(skill-addresses): 新增技能地址修改记录,支持单次记录回退

后端:
- 每次「应用」写一份 {id}.manifest.json(记录本次 from→to 替换明细/文件数/处数),
  放在备份目录的同级,避免被回退误恢复或计入文件数
- GET /backups 返回每条记录的 replaced_total + replacements;POST /rollback/{id} 回退单条

前端:
- 把「撤销上次」升级为「修改记录」面板:每条显示时间/改了哪些地址/文件数,
  带「回退此次」(二次确认);应用后自动展开
- BackupEntry 类型扩展 replacedTotal + replacements

同步更新前后端 CLAUDE.md。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-22 18:05:05 +08:00
1 parent 7487395e44
commit 06b169d7a1
5 files changed
+245 -28

No files matched your search

+128 -18
View File
@@ -9,6 +9,7 @@ import {
Eye,
FileCode,
Globe,
History,
Info,
Loader2,
Network,
@@ -90,6 +91,11 @@ export function SkillAddressesPanel() {
const [applyProg, setApplyProg] = useState<ApplyProgress | null>(null);
const [lastBackupId, setLastBackupId] = useState<string | null>(null);
// 修改记录(每次「应用」生成一条可单独回退的记录)。
const [showHistory, setShowHistory] = useState(false);
const [confirmRollbackId, setConfirmRollbackId] = useState<string | null>(null);
const [rollingBack, setRollingBack] = useState(false);
const [segFind, setSegFind] = useState("");
const [segRepl, setSegRepl] = useState("");
@@ -205,6 +211,7 @@ export function SkillAddressesPanel() {
setLastBackupId(result.backupId);
setEdits({});
setPreview(null);
setShowHistory(true); // 应用后自动展开修改记录,便于查看/回退本次
await refetchBackups();
await runScan(kinds);
} catch (err) {
@@ -215,23 +222,29 @@ export function SkillAddressesPanel() {
}
};
const runRollback = async () => {
const id = lastBackupId ?? backups[0]?.backupId;
if (!id) {
toast.error("没有可撤销的备份");
return;
}
/** 回退单次修改记录:从该次备份恢复文件(不影响其他记录)。 */
const rollbackOne = async (backupId: string) => {
setRollingBack(true);
try {
const { restored } = await rollbackBackup(id);
toast.success(`已从备份恢复 ${restored} 个文件`);
setLastBackupId(null);
const { restored } = await rollbackBackup(backupId);
toast.success(`已从该记录回退 ${restored} 个文件`);
setConfirmRollbackId(null);
if (backupId === lastBackupId) setLastBackupId(null);
await refetchBackups();
await runScan(kinds);
} catch (err) {
toast.error(err instanceof Error ? err.message : "撤销失败");
toast.error(err instanceof Error ? err.message : "回退失败");
} finally {
setRollingBack(false);
}
};
const fmtTime = (iso: string) => {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
// 多关键词检索:空格分隔,每个关键词都要命中(地址或技能名任一包含)才显示。
// 这样「uat 4.cn」能同时收窄到既含 uat 又含 4.cn 的地址,应对地址过多的场景。
const tokens = useMemo(
@@ -247,7 +260,6 @@ export function SkillAddressesPanel() {
}, [groups, tokens]);
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 />;
@@ -278,13 +290,16 @@ export function SkillAddressesPanel() {
<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="从最近一次的磁盘备份恢复"
onClick={() => setShowHistory((v) => !v)}
className={`inline-flex cursor-pointer items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-semibold shadow-sm transition-all ${
showHistory
? "border-amber-300 bg-amber-50 text-amber-700"
: "border-slate-200 bg-white text-slate-600 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
}`}
title="查看每次修改记录,可针对单次回退"
>
<Undo2 className="size-[15px]" />
撤销上次
<History className="size-[15px]" />
修改记录{backups.length > 0 ? `(${backups.length})` : ""}
</button>
<button
type="button"
@@ -302,7 +317,7 @@ export function SkillAddressesPanel() {
<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>。替换为精确整串匹配,不会误伤子串。
<code className="rounded bg-slate-100 px-1">skills/custom</code> 下所有 .md / .py),按<strong>唯一地址</strong>聚合其全部出现位置(跨 .md 与 .py)。填写「替换为」后<strong>预览</strong>命中再<strong>应用</strong>;每次应用都会自动磁盘备份并生成一条<strong>修改记录</strong>,可在「修改记录」里针对<strong>单次记录回退</strong>。替换为精确整串匹配,不会误伤子串。
</span>
</p>
</div>
@@ -320,6 +335,101 @@ export function SkillAddressesPanel() {
</div>
)}
{/* 修改记录:每次应用一条,可针对单次回退 */}
{showHistory && (
<div className="mb-6 overflow-hidden rounded-2xl border border-amber-200/70 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-amber-100 bg-amber-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
<span className="flex items-center gap-2">
<History className="size-4 text-amber-600" />
修改记录({backups.length})
</span>
<button
type="button"
onClick={() => refetchBackups()}
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
刷新
</button>
</div>
{backups.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-slate-400">
暂无修改记录。应用一次替换后,这里会生成一条可回退的记录。
</div>
) : (
<ul className="max-h-96 divide-y divide-slate-100 overflow-auto">
{backups.map((b) => (
<li key={b.backupId} className="px-4 py-3">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
{fmtTime(b.createdAt)}
{b.backupId === lastBackupId && (
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700">最近</span>
)}
<span className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-500">
{b.files} 文件 / {b.replacedTotal} 处
</span>
</div>
{b.replacements.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{b.replacements.slice(0, 4).map((r, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-md bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-500">
<span className="line-through">{r.from}</span>
<span className="text-slate-400">→</span>
<span className="font-medium text-blue-700">{r.to}</span>
</span>
))}
{b.replacements.length > 4 && (
<span className="text-[11px] text-slate-400">等 {b.replacements.length} 项</span>
)}
</div>
) : (
<div className="mt-1 font-mono text-xs text-slate-400">{b.backupId}</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{confirmRollbackId === b.backupId ? (
<>
<button
type="button"
onClick={() => rollbackOne(b.backupId)}
disabled={rollingBack || scanning || applying}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-amber-500 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white transition-all hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{rollingBack ? <Loader2 className="size-3.5 animate-spin" /> : <Undo2 className="size-3.5" />}
确认回退
</button>
<button
type="button"
onClick={() => setConfirmRollbackId(null)}
disabled={rollingBack}
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
>
取消
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmRollbackId(b.backupId)}
disabled={scanning || applying}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 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-3.5" />
回退此次
</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</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">
@@ -80,10 +80,18 @@ export interface ApplyResult {
warnings: string[];
}
export interface BackupReplacement {
from: string;
to: string;
kind: AddressKind;
}
export interface BackupEntry {
backupId: string;
files: number;
createdAt: string;
replacedTotal: number;
replacements: BackupReplacement[];
}
/** 扫描进度(逐技能)。 */
@@ -249,7 +257,13 @@ export async function applyReplacementsStream(
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 ?? "" }));
return (data.backups ?? []).map((b) => ({
backupId: b.backup_id,
files: b.files ?? 0,
createdAt: b.created_at ?? "",
replacedTotal: b.replaced_total ?? 0,
replacements: (b.replacements ?? []).map((r: any) => ({ from: r.from, to: r.to, kind: r.kind })),
}));
}
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {