From c23942ddbaaf9c7dadd9b5733197e07fe0b78321 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 6 Aug 2026 15:30:46 +0800 Subject: [PATCH] feat(settings): add local storage tab to display IndexedDB usage and site quota --- CHANGELOG.md | 1 + docs/content/docs/progress/pending-test.mdx | 1 + .../docs/progress/pending-test.zh-CN.mdx | 1 + .../components/layout/app-config-modal.tsx | 6 + .../layout/config-local-storage.tsx | 126 ++++++++++++++++++ web/src/i18n/locales/en-US.ts | 21 ++- web/src/i18n/locales/zh-CN.ts | 21 ++- web/src/services/local-storage-usage.ts | 55 ++++++++ web/src/stores/use-config-store.ts | 2 +- 9 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 web/src/components/layout/config-local-storage.tsx create mode 100644 web/src/services/local-storage-usage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1371b9..28da3b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ++ [新增] 配置页新增本地存储面板,可查看 Infinite Canvas 的 IndexedDB、站点配额和各对象仓库占用情况。 + [修复] 本地图片清理时保留生图与视频工作台历史引用的文件,避免生成记录仍在但图片丢失。 + [修复] 画布将空格键专用于临时切换工具,避免按钮保留焦点后按空格被再次触发。 + [优化] 画布默认改为拖动轻量虚线框选,虚线显示不受画布缩放影响,左下工具可切换选择与移动模式,并支持按住 Control 或空格临时反转当前模式。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 52129fc..3589c78 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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. - 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. +- 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. diff --git a/docs/content/docs/progress/pending-test.zh-CN.mdx b/docs/content/docs/progress/pending-test.zh-CN.mdx index 0ef06db..5234eae 100644 --- a/docs/content/docs/progress/pending-test.zh-CN.mdx +++ b/docs/content/docs/progress/pending-test.zh-CN.mdx @@ -56,6 +56,7 @@ description: 当前版本已实现但仍需人工验证的变更项 - 画布多图尺寸调整:子图展开时开始拖动尺寸控制点,应先收起图片组一次,再正常调整根节点尺寸,不应触发 React 循环更新或应用错误页。 - 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点,自动缩放不得超过 100%;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。 - 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。 +- 本地存储设置:配置页应显示「本地存储」Tab,进入后只读取 `infinite-canvas` 数据库,自动统计 IndexedDB 占用、站点总占用、浏览器配额与使用率,并按对象仓库显示记录数及内容体积;新增或删除图片、音视频、生成记录后点击刷新,数据应同步变化,统计期间页面保持可操作。 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 - 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。 - 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。 diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index ff4146c..6779e2a 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { ModelPicker } from "@/components/model-picker"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; +import { ConfigLocalStorage } from "@/components/layout/config-local-storage"; import type { AppLocale } from "@/i18n"; import { exportAppConfig, importAppConfig } from "@/services/config-file"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; @@ -311,6 +312,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" ), }, + { + key: "local-storage", + label: t("config.tabs.localStorage"), + children: , + }, ]} /> {showDoneButton ? ( diff --git a/web/src/components/layout/config-local-storage.tsx b/web/src/components/layout/config-local-storage.tsx new file mode 100644 index 0000000..1ae4e96 --- /dev/null +++ b/web/src/components/layout/config-local-storage.tsx @@ -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 = { + 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(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 ( + + + + + + + {t("config.localStorage.title")} + + {t("config.localStorage.description")} + + } loading={loading} onClick={() => void refresh()}> + {t("config.localStorage.refresh")} + + + {error ? : null} + {!usage && loading ? ( + + ) : usage ? ( + <> + + } label={t("config.localStorage.indexedDbUsage")} value={formatStorageBytes(indexedDbBytes)} hint={t("config.localStorage.contentEstimate")} /> + } label={t("config.localStorage.siteUsage")} value={formatStorageBytes(usage.usage)} hint={t("config.localStorage.siteUsageHint")} /> + } label={t("config.localStorage.quota")} value={formatStorageBytes(usage.quota)} hint={t("config.localStorage.quotaHint")} /> + + + + {t("config.localStorage.quotaProgress")} + {percent.toFixed(2)}% + + + + > + ) : null} + + {usage?.databases.map((database) => ( + + + + {t("config.localStorage.mainDatabase")} + {database.name} · v{database.version} + + {formatStorageBytes(database.bytes)} + + + {database.stores.map((store) => ( + + + {storeLabel(store.name, t)} + {store.name} + + {t("config.localStorage.records", { count: store.records })} + {formatStorageBytes(store.bytes)} + + ))} + + + ))} + + ); +} + +function StorageMetric({ icon, label, value, hint }: { icon: ReactNode; label: string; value: string; hint: string }) { + return ( + + {icon}{label} + {value} + {hint} + + ); +} + +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`; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 4be7f47..85f7a12 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -357,12 +357,29 @@ export default { config: { title: "Settings & Preferences", invalidFile: "The settings file format is invalid", - description: "Provider aggregation, model selection, and sync preferences", - modalDescription: "Provider aggregation, default models, and sync preferences", + description: "Providers, model selection, sync, and local storage", + modalDescription: "Providers, default models, sync, and local storage", tabs: { channels: "Providers", preferences: "Preferences", 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: { add: "Add source", diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 2c55e21..69cde40 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -357,12 +357,29 @@ export default { config: { title: "配置与用户偏好", invalidFile: "配置文件格式不正确", - description: "渠道聚合、模型选择和同步偏好", - modalDescription: "渠道聚合、默认模型和同步偏好", + description: "渠道聚合、模型选择、同步与本地存储", + modalDescription: "渠道聚合、默认模型、同步与本地存储", tabs: { channels: "渠道", preferences: "偏好设置", 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: { add: "新增来源", diff --git a/web/src/services/local-storage-usage.ts b/web/src/services/local-storage-usage.ts new file mode 100644 index 0000000..c5eb16f --- /dev/null +++ b/web/src/services/local-storage-usage.ts @@ -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 { + 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((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((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; +} diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 2097921..d00e32e 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -60,7 +60,7 @@ export type WebdavSyncConfig = { directory: 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"; const CHANNEL_MODEL_SEPARATOR = "::";