feat(settings): add local storage tab to display IndexedDB usage and site quota

This commit is contained in:
HouYunFei
2026-08-06 15:30:46 +08:00
parent a4074f7413
commit c23942ddba
9 changed files with 229 additions and 5 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
+ [新增] 配置页新增本地存储面板,可查看 Infinite Canvas 的 IndexedDB、站点配额和各对象仓库占用情况。
+ [修复] 本地图片清理时保留生图与视频工作台历史引用的文件,避免生成记录仍在但图片丢失。 + [修复] 本地图片清理时保留生图与视频工作台历史引用的文件,避免生成记录仍在但图片丢失。
+ [修复] 画布将空格键专用于临时切换工具,避免按钮保留焦点后按空格被再次触发。 + [修复] 画布将空格键专用于临时切换工具,避免按钮保留焦点后按空格被再次触发。
+ [优化] 画布默认改为拖动轻量虚线框选,虚线显示不受画布缩放影响,左下工具可切换选择与移动模式,并支持按住 Control 或空格临时反转当前模式。 + [优化] 画布默认改为拖动轻量虚线框选,虚线显示不受画布缩放影响,左下工具可切换选择与移动模式,并支持按住 Control 或空格临时反转当前模式。
@@ -23,5 +23,6 @@ The current release needs manual verification in these areas:
- Prompt Center layout, source synchronization, search, asset insertion, prompt detail dialogs, and compact split-layout prompt pickers in the image and video workbenches. - Prompt Center layout, source synchronization, search, asset insertion, prompt detail dialogs, and compact split-layout prompt pickers in the image and video workbenches.
- Workbench history image cleanup: after generating content in the image or video workbench, deleting an asset, canvas image node, canvas content, or canvas project must not remove thumbnails, results, or references from generation history; deleting a generation record should still remove its dedicated local files. - Workbench history image cleanup: after generating content in the image or video workbench, deleting an asset, canvas image node, canvas content, or canvas project must not remove thumbnails, results, or references from generation history; deleting a generation record should still remove its dedicated local files.
- Settings import/export, Volcengine Ark protocol handling, and WebDAV-related configuration. - Settings import/export, Volcengine Ark protocol handling, and WebDAV-related configuration.
- Local storage settings: the Settings page should provide a Local storage tab that reads only the `infinite-canvas` database and shows IndexedDB usage, total site usage, browser quota, quota percentage, and per-object-store record counts and content size; refreshing after local data changes should update the figures without blocking the rest of the page.
The Chinese version contains the complete item-by-item acceptance checklist. The Chinese version contains the complete item-by-item acceptance checklist.
@@ -56,6 +56,7 @@ description: 当前版本已实现但仍需人工验证的变更项
- 画布多图尺寸调整:子图展开时开始拖动尺寸控制点,应先收起图片组一次,再正常调整根节点尺寸,不应触发 React 循环更新或应用错误页。 - 画布多图尺寸调整:子图展开时开始拖动尺寸控制点,应先收起图片组一次,再正常调整根节点尺寸,不应触发 React 循环更新或应用错误页。
- 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点,自动缩放不得超过 100%;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。 - 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点,自动缩放不得超过 100%;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。
- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。 - 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。
- 本地存储设置:配置页应显示「本地存储」Tab,进入后只读取 `infinite-canvas` 数据库,自动统计 IndexedDB 占用、站点总占用、浏览器配额与使用率,并按对象仓库显示记录数及内容体积;新增或删除图片、音视频、生成记录后点击刷新,数据应同步变化,统计期间页面保持可操作。
- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。
- 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。 - 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。
- 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。 - 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker"; import { ModelPicker } from "@/components/model-picker";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
import { ConfigLocalStorage } from "@/components/layout/config-local-storage";
import type { AppLocale } from "@/i18n"; import type { AppLocale } from "@/i18n";
import { exportAppConfig, importAppConfig } from "@/services/config-file"; import { exportAppConfig, importAppConfig } from "@/services/config-file";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
@@ -311,6 +312,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Form> </Form>
), ),
}, },
{
key: "local-storage",
label: t("config.tabs.localStorage"),
children: <ConfigLocalStorage active={activeTab === "local-storage"} />,
},
]} ]}
/> />
{showDoneButton ? ( {showDoneButton ? (
@@ -0,0 +1,126 @@
import { Alert, Button, Progress, Spin } from "antd";
import type { TFunction } from "i18next";
import { Database, HardDrive, Layers3, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { readLocalStorageUsage, type LocalStorageUsage } from "@/services/local-storage-usage";
const storeLabelKeys: Record<string, string> = {
app_state: "appState",
image_files: "images",
media_files: "media",
image_generation_logs: "imageLogs",
video_generation_logs: "videoLogs",
agent_chat_messages: "agentMessages",
prompt_cache: "promptCache",
};
export function ConfigLocalStorage({ active }: { active: boolean }) {
const { t } = useTranslation();
const [usage, setUsage] = useState<LocalStorageUsage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
setUsage(await readLocalStorageUsage());
} catch (reason) {
setError(reason instanceof Error ? reason.message : t("config.localStorage.readFailed"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
if (active && !usage) void refresh();
}, [active, refresh, usage]);
const indexedDbBytes = usage?.contentBytes ?? 0;
const percent = usage ? Math.min(100, (usage.usage / usage.quota) * 100) : 0;
return (
<div className="space-y-3">
<section className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4" />
{t("config.localStorage.title")}
</div>
<div className="mt-1 text-xs text-stone-500">{t("config.localStorage.description")}</div>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void refresh()}>
{t("config.localStorage.refresh")}
</Button>
</div>
{error ? <Alert className="mt-4" type="error" showIcon message={t("config.localStorage.readFailed")} description={error} /> : null}
{!usage && loading ? (
<div className="flex min-h-48 items-center justify-center"><Spin /></div>
) : usage ? (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<StorageMetric icon={<Database className="size-4" />} label={t("config.localStorage.indexedDbUsage")} value={formatStorageBytes(indexedDbBytes)} hint={t("config.localStorage.contentEstimate")} />
<StorageMetric icon={<HardDrive className="size-4" />} label={t("config.localStorage.siteUsage")} value={formatStorageBytes(usage.usage)} hint={t("config.localStorage.siteUsageHint")} />
<StorageMetric icon={<Layers3 className="size-4" />} label={t("config.localStorage.quota")} value={formatStorageBytes(usage.quota)} hint={t("config.localStorage.quotaHint")} />
</div>
<div className="mt-4">
<div className="mb-1 flex justify-between text-xs text-stone-500">
<span>{t("config.localStorage.quotaProgress")}</span>
<span className="tabular-nums">{percent.toFixed(2)}%</span>
</div>
<Progress percent={percent} showInfo={false} />
</div>
</>
) : null}
</section>
{usage?.databases.map((database) => (
<section key={database.name} className="overflow-hidden rounded-lg border border-stone-200 dark:border-stone-800">
<div className="flex items-center justify-between gap-3 border-b border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{t("config.localStorage.mainDatabase")}</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-stone-500">{database.name} · v{database.version}</div>
</div>
<div className="shrink-0 text-sm font-medium tabular-nums">{formatStorageBytes(database.bytes)}</div>
</div>
<div className="divide-y divide-stone-200 dark:divide-stone-800">
{database.stores.map((store) => (
<div key={store.name} className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-4 px-4 py-3 text-sm">
<div className="min-w-0">
<div className="truncate font-medium">{storeLabel(store.name, t)}</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-stone-500">{store.name}</div>
</div>
<div className="text-right text-xs text-stone-500 tabular-nums">{t("config.localStorage.records", { count: store.records })}</div>
<div className="w-20 text-right font-medium tabular-nums">{formatStorageBytes(store.bytes)}</div>
</div>
))}
</div>
</section>
))}
</div>
);
}
function StorageMetric({ icon, label, value, hint }: { icon: ReactNode; label: string; value: string; hint: string }) {
return (
<div className="rounded-lg bg-stone-100/70 p-3 dark:bg-stone-900/70">
<div className="flex items-center gap-2 text-xs text-stone-500">{icon}{label}</div>
<div className="mt-2 text-xl font-semibold tabular-nums">{value}</div>
<div className="mt-1 text-[11px] text-stone-500">{hint}</div>
</div>
);
}
function storeLabel(name: string, t: TFunction) {
const key = storeLabelKeys[name];
return key ? t(`config.localStorage.stores.${key}`) : name;
}
function formatStorageBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
+19 -2
View File
@@ -357,12 +357,29 @@ export default {
config: { config: {
title: "Settings & Preferences", title: "Settings & Preferences",
invalidFile: "The settings file format is invalid", invalidFile: "The settings file format is invalid",
description: "Provider aggregation, model selection, and sync preferences", description: "Providers, model selection, sync, and local storage",
modalDescription: "Provider aggregation, default models, and sync preferences", modalDescription: "Providers, default models, sync, and local storage",
tabs: { tabs: {
channels: "Providers", channels: "Providers",
preferences: "Preferences", preferences: "Preferences",
promptSources: "Prompt sources", promptSources: "Prompt sources",
localStorage: "Local storage",
},
localStorage: {
title: "IndexedDB storage usage",
description: "View browser data saved by Infinite Canvas, grouped by object store.",
indexedDbUsage: "IndexedDB usage",
siteUsage: "Total site usage",
quota: "Available quota",
contentEstimate: "Estimated from stored content",
siteUsageHint: "Includes IndexedDB and other site data",
quotaHint: "Allocated dynamically by the browser",
quotaProgress: "Site quota usage",
mainDatabase: "Infinite Canvas data",
records: "{{count}} records",
refresh: "Refresh usage",
readFailed: "Failed to read local storage",
stores: { appState: "App state", images: "Image files", media: "Audio and video files", imageLogs: "Image history", videoLogs: "Video history", agentMessages: "Agent messages", promptCache: "Prompt cache" },
}, },
promptSources: { promptSources: {
add: "Add source", add: "Add source",
+19 -2
View File
@@ -357,12 +357,29 @@ export default {
config: { config: {
title: "配置与用户偏好", title: "配置与用户偏好",
invalidFile: "配置文件格式不正确", invalidFile: "配置文件格式不正确",
description: "渠道聚合、模型选择同步偏好", description: "渠道聚合、模型选择同步与本地存储",
modalDescription: "渠道聚合、默认模型同步偏好", modalDescription: "渠道聚合、默认模型同步与本地存储",
tabs: { tabs: {
channels: "渠道", channels: "渠道",
preferences: "偏好设置", preferences: "偏好设置",
promptSources: "提示词来源", promptSources: "提示词来源",
localStorage: "本地存储",
},
localStorage: {
title: "IndexedDB 存储使用情况",
description: "查看 Infinite Canvas 在浏览器中保存的数据量,并按对象仓库统计内容体积。",
indexedDbUsage: "IndexedDB 占用",
siteUsage: "站点总占用",
quota: "可用配额",
contentEstimate: "按仓库内容估算",
siteUsageHint: "包含 IndexedDB 等站点数据",
quotaHint: "由浏览器动态分配",
quotaProgress: "站点配额使用率",
mainDatabase: "Infinite Canvas 主数据",
records: "{{count}} 条",
refresh: "刷新统计",
readFailed: "读取本地存储失败",
stores: { appState: "应用状态", images: "图片文件", media: "音视频文件", imageLogs: "生图记录", videoLogs: "视频记录", agentMessages: "Agent 消息", promptCache: "提示词缓存" },
}, },
promptSources: { promptSources: {
add: "新增来源", add: "新增来源",
+55
View File
@@ -0,0 +1,55 @@
export type IndexedDbStoreUsage = { name: string; records: number; bytes: number };
export type IndexedDbDatabaseUsage = { name: string; version: number; bytes: number; stores: IndexedDbStoreUsage[] };
export type LocalStorageUsage = { usage: number; quota: number; contentBytes: number; databases: IndexedDbDatabaseUsage[] };
export async function readLocalStorageUsage(): Promise<LocalStorageUsage> {
const [estimate, database] = await Promise.all([navigator.storage.estimate(), readDatabaseUsage("infinite-canvas")]);
return { usage: estimate.usage!, quota: estimate.quota!, contentBytes: database.bytes, databases: [database] };
}
function readDatabaseUsage(name: string) {
return new Promise<IndexedDbDatabaseUsage>((resolve, reject) => {
const request = indexedDB.open(name);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const database = request.result;
const names = Array.from(database.objectStoreNames);
if (!names.length) {
database.close();
resolve({ name, version: database.version, bytes: 0, stores: [] });
return;
}
const transaction = database.transaction(names, "readonly");
Promise.all(names.map((storeName) => readStoreUsage(transaction.objectStore(storeName))))
.then((stores) => resolve({ name, version: database.version, bytes: stores.reduce((total, store) => total + store.bytes, 0), stores: stores.sort((a, b) => b.bytes - a.bytes) }))
.catch(reject)
.finally(() => database.close());
};
});
}
function readStoreUsage(store: IDBObjectStore) {
return new Promise<IndexedDbStoreUsage>((resolve, reject) => {
let records = 0;
let bytes = 0;
const request = store.openCursor();
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve({ name: store.name, records, bytes });
return;
}
records += 1;
bytes += valueBytes(cursor.value);
cursor.continue();
};
});
}
function valueBytes(value: unknown) {
if (value instanceof Blob) return value.size;
if (value instanceof ArrayBuffer) return value.byteLength;
if (ArrayBuffer.isView(value)) return value.byteLength;
return new TextEncoder().encode(typeof value === "string" ? value : JSON.stringify(value)).byteLength;
}
+1 -1
View File
@@ -60,7 +60,7 @@ export type WebdavSyncConfig = {
directory: string; directory: string;
lastSyncedAt: string; lastSyncedAt: string;
}; };
export type ConfigTabKey = "channels" | "preferences" | "prompt-sources" | "webdav"; export type ConfigTabKey = "channels" | "preferences" | "prompt-sources" | "webdav" | "local-storage";
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
const CHANNEL_MODEL_SEPARATOR = "::"; const CHANNEL_MODEL_SEPARATOR = "::";