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:
1 parent
7487395e44
commit
06b169d7a1
5 files changed
+245
-28
No files matched your search
@@ -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 }> {
|
||||
|
||||
Reference in new issue
Block a user