增加了基础的块功能
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Editor Quick Checklist
|
||||
|
||||
1. Select 2-3 paragraphs, open block menu, convert to code block.
|
||||
Expected: merged into 1 code block, selection remains on that block.
|
||||
|
||||
2. Select 2-3 contiguous blocks, click move up/down.
|
||||
Expected: all selected blocks move together in one step.
|
||||
|
||||
3. Select 2-3 contiguous blocks, drag using the block handle.
|
||||
Expected: drag preview shows "will move N blocks", all blocks move together.
|
||||
|
||||
4. Select multiple blocks, run copy/delete/duplicate from block menu.
|
||||
Expected: operation applies to all selected blocks.
|
||||
|
||||
5. After each multi-block action, press `Ctrl/Cmd + Z` once.
|
||||
Expected: the full action is reverted in one undo step.
|
||||
|
||||
6. Open block menu and expand "查看快捷键".
|
||||
Expected: shortcuts are visible and menu remains usable.
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "node --no-warnings --experimental-strip-types scripts/run-editor-block-ops-tests.mjs",
|
||||
"check": "npm run lint -- --max-warnings=0 && npx tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildDropIndicatorText,
|
||||
computeSpanTargetStart,
|
||||
mergeBlockTexts,
|
||||
moveContiguousSpan,
|
||||
} from "../src/lib/editor-block-ops.ts";
|
||||
|
||||
const cases = [
|
||||
() => assert.equal(mergeBlockTexts(["A", "B", "C"]), "A\n\nB\n\nC"),
|
||||
() => assert.equal(computeSpanTargetStart(6, 2, 3, 3), 2),
|
||||
() => assert.equal(computeSpanTargetStart(6, 3, 4, 1), 1),
|
||||
() => assert.equal(computeSpanTargetStart(6, 1, 2, 5), 3),
|
||||
() => {
|
||||
const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 2, 3, 1);
|
||||
assert.equal(moved.changed, true);
|
||||
assert.equal(moved.targetStart, 1);
|
||||
assert.deepEqual(moved.reordered, ["A", "C", "D", "B", "E"]);
|
||||
},
|
||||
() => {
|
||||
const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 1, 2, 5);
|
||||
assert.equal(moved.changed, true);
|
||||
assert.equal(moved.targetStart, 3);
|
||||
assert.deepEqual(moved.reordered, ["A", "D", "E", "B", "C"]);
|
||||
},
|
||||
() => {
|
||||
const moved = moveContiguousSpan(["A", "B", "C", "D", "E"], 1, 3, 2);
|
||||
assert.equal(moved.changed, false);
|
||||
assert.equal(moved.targetStart, 1);
|
||||
assert.deepEqual(moved.reordered, ["A", "B", "C", "D", "E"]);
|
||||
},
|
||||
() => {
|
||||
assert.equal(buildDropIndicatorText(0, 4), "将插入到第 1 块之前");
|
||||
assert.equal(buildDropIndicatorText(2, 4), "将插入到第 3 块之前");
|
||||
assert.equal(buildDropIndicatorText(9, 4), "将插入到文档末尾");
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, run] of cases.entries()) {
|
||||
run();
|
||||
console.log(`ok ${index + 1}`);
|
||||
}
|
||||
|
||||
console.log(`All ${cases.length} editor block operation checks passed.`);
|
||||
@@ -353,3 +353,15 @@ ul[data-type="taskList"],
|
||||
/* Let pre handle the background */
|
||||
}
|
||||
|
||||
.ProseMirror > .block-drag-active {
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--accent) / 0.45);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.7);
|
||||
}
|
||||
|
||||
.ProseMirror > .block-selected {
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25));
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.28);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2, History, Trash2 } from "lucide-react";
|
||||
import { ResizableSidebar } from "@/components/sidebar";
|
||||
import { PromptManagement } from "@/components/settings/prompt-management";
|
||||
import { ImportProvider } from "@/components/import-context";
|
||||
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
||||
import { useConfirm } from "@/components/confirm-provider";
|
||||
import { cleanupAllPageHistories, estimateAllPageHistoriesUsage, trimAllPageHistoriesToRecent } from "@/lib/page-history";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const confirm = useConfirm();
|
||||
@@ -21,6 +22,8 @@ export default function SettingsPage() {
|
||||
setTableLineHeight,
|
||||
timezone,
|
||||
setTimezone,
|
||||
localHistory,
|
||||
setLocalHistory,
|
||||
aiConfig,
|
||||
setAIConfig,
|
||||
} = useSettingsStore();
|
||||
@@ -30,8 +33,15 @@ export default function SettingsPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [historyMsg, setHistoryMsg] = useState<string | null>(null);
|
||||
const [trimRecentCount, setTrimRecentCount] = useState(10);
|
||||
const [historyStats, setHistoryStats] = useState({ keys: 0, snapshots: 0, bytes: 0 });
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHistoryStats(estimateAllPageHistoriesUsage());
|
||||
}, []);
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
@@ -151,6 +161,85 @@ export default function SettingsPage() {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<History size={18} className="text-primary" />
|
||||
<h2 className="text-lg font-semibold md:text-xl">本地历史策略</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
控制浏览器本地历史快照占用空间。保存后会在后续写入时自动清理,也可立即手动清理一次。
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">每篇最多保留: {localHistory.maxSnapshotsPerPage} 条</span>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="200"
|
||||
step="1"
|
||||
value={localHistory.maxSnapshotsPerPage}
|
||||
onChange={(e) =>
|
||||
setLocalHistory({ maxSnapshotsPerPage: parseInt(e.target.value, 10) })
|
||||
}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">保留时长: {localHistory.retentionDays} 天</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="365"
|
||||
step="1"
|
||||
value={localHistory.retentionDays}
|
||||
onChange={(e) => setLocalHistory({ retentionDays: parseInt(e.target.value, 10) })}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div>本地历史键: {historyStats.keys}</div>
|
||||
<div>快照总数: {historyStats.snapshots}</div>
|
||||
<div>估算占用: {(historyStats.bytes / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const result = cleanupAllPageHistories();
|
||||
setHistoryStats(estimateAllPageHistoriesUsage());
|
||||
setHistoryMsg(`已清理本地历史键 ${result.touched} 项`);
|
||||
}}
|
||||
className="ui-btn-secondary"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
立即清理一次
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={trimRecentCount}
|
||||
onChange={(e) => setTrimRecentCount(Math.max(1, Math.min(200, parseInt(e.target.value || "1", 10))))}
|
||||
className="ui-input h-9 w-24"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const result = trimAllPageHistoriesToRecent(trimRecentCount);
|
||||
setHistoryStats(estimateAllPageHistoriesUsage());
|
||||
setHistoryMsg(
|
||||
`已处理 ${result.touched} 项,删除快照 ${result.removedSnapshots} 条(每篇保留最近 ${trimRecentCount} 条)`
|
||||
);
|
||||
}}
|
||||
className="ui-btn-secondary"
|
||||
>
|
||||
仅保留最近 N 条
|
||||
</button>
|
||||
</div>
|
||||
{historyMsg && <span className="text-xs text-muted-foreground">{historyMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Sparkles size={18} className="text-primary" />
|
||||
|
||||
+1104
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildDropIndicatorText,
|
||||
computeSpanTargetStart,
|
||||
mergeBlockTexts,
|
||||
moveContiguousSpan,
|
||||
} from "./editor-block-ops";
|
||||
|
||||
test("mergeBlockTexts joins with blank lines", () => {
|
||||
assert.equal(mergeBlockTexts(["A", "B", "C"]), "A\n\nB\n\nC");
|
||||
});
|
||||
|
||||
test("computeSpanTargetStart keeps position when dropping into current span", () => {
|
||||
const next = computeSpanTargetStart(6, 2, 3, 3);
|
||||
assert.equal(next, 2);
|
||||
});
|
||||
|
||||
test("computeSpanTargetStart moves span upward", () => {
|
||||
const next = computeSpanTargetStart(6, 3, 4, 1);
|
||||
assert.equal(next, 1);
|
||||
});
|
||||
|
||||
test("computeSpanTargetStart moves span downward", () => {
|
||||
const next = computeSpanTargetStart(6, 1, 2, 5);
|
||||
assert.equal(next, 3);
|
||||
});
|
||||
|
||||
test("moveContiguousSpan reorders blocks upward", () => {
|
||||
const source = ["A", "B", "C", "D", "E"];
|
||||
const moved = moveContiguousSpan(source, 2, 3, 1);
|
||||
assert.equal(moved.changed, true);
|
||||
assert.equal(moved.targetStart, 1);
|
||||
assert.deepEqual(moved.reordered, ["A", "C", "D", "B", "E"]);
|
||||
});
|
||||
|
||||
test("moveContiguousSpan reorders blocks to document end", () => {
|
||||
const source = ["A", "B", "C", "D", "E"];
|
||||
const moved = moveContiguousSpan(source, 1, 2, 5);
|
||||
assert.equal(moved.changed, true);
|
||||
assert.equal(moved.targetStart, 3);
|
||||
assert.deepEqual(moved.reordered, ["A", "D", "E", "B", "C"]);
|
||||
});
|
||||
|
||||
test("moveContiguousSpan does not change on internal drop", () => {
|
||||
const source = ["A", "B", "C", "D", "E"];
|
||||
const moved = moveContiguousSpan(source, 1, 3, 2);
|
||||
assert.equal(moved.changed, false);
|
||||
assert.equal(moved.targetStart, 1);
|
||||
assert.deepEqual(moved.reordered, source);
|
||||
});
|
||||
|
||||
test("buildDropIndicatorText covers start, middle and end", () => {
|
||||
assert.equal(buildDropIndicatorText(0, 4), "将插入到第 1 块之前");
|
||||
assert.equal(buildDropIndicatorText(2, 4), "将插入到第 3 块之前");
|
||||
assert.equal(buildDropIndicatorText(9, 4), "将插入到文档末尾");
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export function mergeBlockTexts(texts: string[]): string {
|
||||
return texts.join("\n\n");
|
||||
}
|
||||
|
||||
export function computeSpanTargetStart(
|
||||
totalCount: number,
|
||||
sourceStartIndex: number,
|
||||
sourceEndIndex: number,
|
||||
insertionIndex: number
|
||||
): number {
|
||||
if (totalCount <= 0) return 0;
|
||||
if (sourceStartIndex < 0 || sourceEndIndex < sourceStartIndex || sourceEndIndex >= totalCount) return sourceStartIndex;
|
||||
|
||||
const movedCount = sourceEndIndex - sourceStartIndex + 1;
|
||||
const clampedInsertion = Math.max(0, Math.min(totalCount, insertionIndex));
|
||||
if (clampedInsertion >= sourceStartIndex && clampedInsertion <= sourceEndIndex + 1) return sourceStartIndex;
|
||||
return clampedInsertion <= sourceStartIndex ? clampedInsertion : clampedInsertion - movedCount;
|
||||
}
|
||||
|
||||
export function moveContiguousSpan<T>(
|
||||
items: T[],
|
||||
sourceStartIndex: number,
|
||||
sourceEndIndex: number,
|
||||
insertionIndex: number
|
||||
): { reordered: T[]; targetStart: number; changed: boolean } {
|
||||
if (items.length === 0) return { reordered: items, targetStart: 0, changed: false };
|
||||
if (sourceStartIndex < 0 || sourceEndIndex < sourceStartIndex || sourceEndIndex >= items.length) {
|
||||
return { reordered: items, targetStart: sourceStartIndex, changed: false };
|
||||
}
|
||||
|
||||
const targetStart = computeSpanTargetStart(items.length, sourceStartIndex, sourceEndIndex, insertionIndex);
|
||||
if (targetStart === sourceStartIndex) {
|
||||
return { reordered: items, targetStart, changed: false };
|
||||
}
|
||||
|
||||
const moved = items.slice(sourceStartIndex, sourceEndIndex + 1);
|
||||
const remaining = [...items.slice(0, sourceStartIndex), ...items.slice(sourceEndIndex + 1)];
|
||||
const reordered = [...remaining.slice(0, targetStart), ...moved, ...remaining.slice(targetStart)];
|
||||
return { reordered, targetStart, changed: true };
|
||||
}
|
||||
|
||||
export function buildDropIndicatorText(insertionIndex: number, totalCount: number): string {
|
||||
const clamped = Math.max(0, Math.min(totalCount, insertionIndex));
|
||||
if (totalCount <= 0) return "将插入到文档开头";
|
||||
if (clamped >= totalCount) return "将插入到文档末尾";
|
||||
return `将插入到第 ${clamped + 1} 块之前`;
|
||||
}
|
||||
+173
-12
@@ -1,7 +1,10 @@
|
||||
import type { Page } from "@/lib/store";
|
||||
import type { Page } from "@/lib/store";
|
||||
import { useSettingsStore } from "@/lib/settings-store";
|
||||
|
||||
const MAX_SNAPSHOTS_PER_PAGE = 30;
|
||||
const RETENTION_DAYS = 30;
|
||||
const MIN_SNAPSHOT_INTERVAL_MS = 15_000;
|
||||
const HISTORY_KEY_PREFIX = "noteai:history:";
|
||||
|
||||
export type PageSnapshot = {
|
||||
title: string;
|
||||
@@ -10,28 +13,67 @@ export type PageSnapshot = {
|
||||
};
|
||||
|
||||
function getKey(pageId: string): string {
|
||||
return `noteai:history:${pageId}`;
|
||||
return `${HISTORY_KEY_PREFIX}${pageId}`;
|
||||
}
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function isSnapshot(item: unknown): item is PageSnapshot {
|
||||
return (
|
||||
!!item &&
|
||||
typeof item === "object" &&
|
||||
typeof (item as PageSnapshot).title === "string" &&
|
||||
typeof (item as PageSnapshot).content === "string" &&
|
||||
typeof (item as PageSnapshot).timestamp === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function clampInt(value: number, fallback: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(value)));
|
||||
}
|
||||
|
||||
function getRetentionConfig() {
|
||||
const settings = useSettingsStore.getState().localHistory;
|
||||
return {
|
||||
maxSnapshotsPerPage: clampInt(settings?.maxSnapshotsPerPage ?? MAX_SNAPSHOTS_PER_PAGE, MAX_SNAPSHOTS_PER_PAGE, 5, 200),
|
||||
retentionDays: clampInt(settings?.retentionDays ?? RETENTION_DAYS, RETENTION_DAYS, 1, 365),
|
||||
};
|
||||
}
|
||||
|
||||
function pruneSnapshots(snapshots: PageSnapshot[]): PageSnapshot[] {
|
||||
const { maxSnapshotsPerPage, retentionDays } = getRetentionConfig();
|
||||
const minTimestamp = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
return snapshots
|
||||
.filter((item) => {
|
||||
const ts = new Date(item.timestamp).getTime();
|
||||
if (Number.isNaN(ts)) return false;
|
||||
return ts >= minTimestamp;
|
||||
})
|
||||
.slice(0, maxSnapshotsPerPage);
|
||||
}
|
||||
|
||||
export function getPageHistory(pageId: string): PageSnapshot[] {
|
||||
if (!canUseStorage()) return [];
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(getKey(pageId));
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(item): item is PageSnapshot =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.title === "string" &&
|
||||
typeof item.content === "string" &&
|
||||
typeof item.timestamp === "string"
|
||||
);
|
||||
|
||||
const normalized = parsed.filter(isSnapshot);
|
||||
const pruned = pruneSnapshots(normalized);
|
||||
|
||||
if (pruned.length !== normalized.length) {
|
||||
localStorage.setItem(getKey(pageId), JSON.stringify(pruned));
|
||||
}
|
||||
|
||||
return pruned;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -39,6 +81,7 @@ export function getPageHistory(pageId: string): PageSnapshot[] {
|
||||
|
||||
export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void {
|
||||
if (!canUseStorage()) return;
|
||||
|
||||
const history = getPageHistory(page.id);
|
||||
const latest = history[0];
|
||||
|
||||
@@ -60,12 +103,130 @@ export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
...history,
|
||||
].slice(0, MAX_SNAPSHOTS_PER_PAGE);
|
||||
];
|
||||
|
||||
try {
|
||||
localStorage.setItem(getKey(page.id), JSON.stringify(next));
|
||||
localStorage.setItem(getKey(page.id), JSON.stringify(pruneSnapshots(next)));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupAllPageHistories(): { touched: number } {
|
||||
if (!canUseStorage()) return { touched: 0 };
|
||||
|
||||
const targets: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
|
||||
targets.push(key);
|
||||
}
|
||||
|
||||
let touched = 0;
|
||||
for (const key of targets) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
localStorage.removeItem(key);
|
||||
touched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = parsed.filter(isSnapshot);
|
||||
const pruned = pruneSnapshots(normalized);
|
||||
if (pruned.length === 0) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(pruned));
|
||||
}
|
||||
|
||||
touched += 1;
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
touched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { touched };
|
||||
}
|
||||
|
||||
export function estimateAllPageHistoriesUsage(): {
|
||||
keys: number;
|
||||
snapshots: number;
|
||||
bytes: number;
|
||||
} {
|
||||
if (!canUseStorage()) return { keys: 0, snapshots: 0, bytes: 0 };
|
||||
|
||||
let keys = 0;
|
||||
let snapshots = 0;
|
||||
let bytes = 0;
|
||||
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
keys += 1;
|
||||
bytes += raw.length * 2;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
snapshots += parsed.filter(isSnapshot).length;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failures here
|
||||
}
|
||||
}
|
||||
|
||||
return { keys, snapshots, bytes };
|
||||
}
|
||||
|
||||
export function trimAllPageHistoriesToRecent(limitPerPage: number): {
|
||||
touched: number;
|
||||
removedSnapshots: number;
|
||||
} {
|
||||
if (!canUseStorage()) return { touched: 0, removedSnapshots: 0 };
|
||||
|
||||
const limit = clampInt(limitPerPage, 10, 1, 200);
|
||||
const targets: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key || !key.startsWith(HISTORY_KEY_PREFIX)) continue;
|
||||
targets.push(key);
|
||||
}
|
||||
|
||||
let touched = 0;
|
||||
let removedSnapshots = 0;
|
||||
|
||||
for (const key of targets) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
localStorage.removeItem(key);
|
||||
touched += 1;
|
||||
continue;
|
||||
}
|
||||
const normalized = parsed.filter(isSnapshot);
|
||||
const prunedByPolicy = pruneSnapshots(normalized);
|
||||
const next = prunedByPolicy.slice(0, limit);
|
||||
removedSnapshots += Math.max(0, normalized.length - next.length);
|
||||
if (next.length === 0) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
}
|
||||
touched += 1;
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
touched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { touched, removedSnapshots };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ interface SettingsState {
|
||||
lineHeight: number;
|
||||
tableLineHeight: number;
|
||||
timezone: string;
|
||||
localHistory: {
|
||||
maxSnapshotsPerPage: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
|
||||
aiConfig: {
|
||||
apiKey: string;
|
||||
@@ -52,6 +56,7 @@ interface SettingsState {
|
||||
setLineHeight: (height: number) => void;
|
||||
setTableLineHeight: (height: number) => void;
|
||||
setTimezone: (timezone: string) => void;
|
||||
setLocalHistory: (config: Partial<{ maxSnapshotsPerPage: number; retentionDays: number }>) => void;
|
||||
setAIConfig: (config: Partial<{ apiKey: string; baseURL: string; model: string }>) => void;
|
||||
|
||||
setPrompts: (prompts: AIPrompt[]) => void;
|
||||
@@ -69,6 +74,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
lineHeight: 1.5,
|
||||
tableLineHeight: 1.2,
|
||||
timezone: "Asia/Shanghai",
|
||||
localHistory: {
|
||||
maxSnapshotsPerPage: 30,
|
||||
retentionDays: 30,
|
||||
},
|
||||
|
||||
aiConfig: {
|
||||
apiKey: "",
|
||||
@@ -83,6 +92,13 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setLineHeight: (height) => set({ lineHeight: height }),
|
||||
setTableLineHeight: (height) => set({ tableLineHeight: height }),
|
||||
setTimezone: (timezone) => set({ timezone }),
|
||||
setLocalHistory: (config) =>
|
||||
set((state) => ({
|
||||
localHistory: {
|
||||
...state.localHistory,
|
||||
...config,
|
||||
},
|
||||
})),
|
||||
setAIConfig: (config) =>
|
||||
set((state) => ({
|
||||
aiConfig: {
|
||||
@@ -108,6 +124,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
lineHeight: state.lineHeight,
|
||||
tableLineHeight: state.tableLineHeight,
|
||||
timezone: state.timezone,
|
||||
localHistory: state.localHistory,
|
||||
aiConfig: {
|
||||
baseURL: state.aiConfig.baseURL,
|
||||
model: state.aiConfig.model,
|
||||
|
||||
Reference in New Issue
Block a user