From 0145f1883c2de93bb4d9ae10727c38e128a0c114 Mon Sep 17 00:00:00 2001 From: Aisland Date: Tue, 1 Sep 2026 12:51:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(console):=20=E8=83=8C=E6=99=AF=E7=85=A7?= =?UTF-8?q?=E7=89=87=E6=94=B9=E5=AD=98=20IndexedDB=20=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E5=9B=BE=E7=89=87=EF=BC=8C=E6=98=BE=E7=A4=BA=E9=9B=B6=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E5=85=A8=E5=88=86=E8=BE=A8=E7=8E=87=EF=BC=88=E6=97=A7?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E5=9B=BE=E4=BB=85=E5=9B=9E=E9=80=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/lib/client.js | 159 ++++++++++++++++++++++------ 01_content/src/client/index.tsx | 29 ++++- 01_content/src/client/photoStore.ts | 40 +++++++ 01_content/src/client/split.tsx | 57 +++++----- 4 files changed, 223 insertions(+), 62 deletions(-) create mode 100644 01_content/src/client/photoStore.ts diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 162deae..fa8ea64 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -17298,7 +17298,8 @@ function ConsolePane() { const [cols, setColsState] = (0, import_react.useState)(() => splitEnv?.console?.getCols?.() ?? 3); const [shape, setShapeState] = (0, import_react.useState)(() => splitEnv?.console?.getShape?.() ?? "square"); const [bg, setBgState] = (0, import_react.useState)(() => splitEnv?.console?.getBg?.() ?? "glow"); - const [bgPhoto, setBgPhoto] = (0, import_react.useState)(() => splitEnv?.console?.getPhoto?.() ?? ""); + const [bgPhoto, setBgPhoto] = (0, import_react.useState)(""); + const photoUrlRef = (0, import_react.useRef)(""); const [openMenu, setOpenMenu] = (0, import_react.useState)(null); const [bgEdit, setBgEdit] = (0, import_react.useState)(null); const [plainHsl, setPlainHslState] = (0, import_react.useState)(() => splitEnv?.console?.getPlainHsl?.() ?? { h: 220, s: 31, l: 6 }); @@ -17325,6 +17326,23 @@ function ConsolePane() { setShapeState(s); splitEnv?.console?.setShape?.(s); }; + const onFilePhoto = async (file) => { + try { + await splitEnv?.console?.setPhoto?.(file); + } catch { + } + const url = URL.createObjectURL(file); + if (photoUrlRef.current) { + try { + URL.revokeObjectURL(photoUrlRef.current); + } catch { + } + } + photoUrlRef.current = url; + setBgPhoto(url); + setBgState("photo"); + splitEnv?.console?.setBg?.("photo"); + }; const onBg = (m) => { if (m === "photo") { const input = document.createElement("input"); @@ -17333,33 +17351,7 @@ function ConsolePane() { input.onchange = () => { const file = input.files?.[0]; if (!file) return; - const url = URL.createObjectURL(file); - const img = new Image(); - img.onload = () => { - const maxDim = 2400; - const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); - const w = Math.max(1, Math.round(img.width * scale)); - const h = Math.max(1, Math.round(img.height * scale)); - const canvas = document.createElement("canvas"); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(url); - return; - } - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - ctx.drawImage(img, 0, 0, w, h); - const webp = canvas.toDataURL("image/webp", 0.9); - const dataUrl = webp.indexOf("data:image/webp") === 0 ? webp : canvas.toDataURL("image/jpeg", 0.9); - URL.revokeObjectURL(url); - setBgPhoto(dataUrl); - setBgState("photo"); - splitEnv?.console?.setBg?.("photo"); - splitEnv?.console?.setPhoto?.(dataUrl); - }; - img.src = url; + onFilePhoto(file); }; input.click(); return; @@ -17463,6 +17455,33 @@ function ConsolePane() { (0, import_react.useEffect)(() => { splitEnv?.console?.refreshPreviews?.(); }, []); + (0, import_react.useEffect)(() => { + let alive = true; + splitEnv?.console?.getPhoto?.().then((url) => { + if (alive && url && url !== photoUrlRef.current) { + if (photoUrlRef.current) { + try { + URL.revokeObjectURL(photoUrlRef.current); + } catch { + } + } + photoUrlRef.current = url; + setBgPhoto(url); + } + }).catch(() => { + }); + return () => { + alive = false; + }; + }, []); + (0, import_react.useEffect)(() => () => { + if (photoUrlRef.current) { + try { + URL.revokeObjectURL(photoUrlRef.current); + } catch { + } + } + }, []); (0, import_react.useEffect)(() => { const iv = window.setInterval(() => setNow(Date.now()), 1e3); return () => window.clearInterval(iv); @@ -18725,6 +18744,61 @@ try { } catch { } +// src/client/photoStore.ts +var DB_NAME = "dsh-worktable"; +var STORE = "consoleBgPhoto"; +var KEY = "original"; +function openDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} +var photoStore = { + /** 保存原始图片(原样存,不重新编码) */ + async save(blob) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(blob, KEY); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }); + }, + /** 读取原始图片;无记录返回 null */ + async load() { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const req = tx.objectStore(STORE).get(KEY); + req.onsuccess = () => { + const v = req.result; + db.close(); + resolve(v instanceof Blob ? v : null); + }; + req.onerror = () => { + db.close(); + reject(req.error); + }; + }); + } +}; + // src/client/index.tsx var import_jsx_runtime2 = require("react/jsx-runtime"); var LOCAL_VERSION = false ? "dev" : "0.2.3"; @@ -19942,17 +20016,38 @@ function WorktableSection(props) { setShape: (s) => persistView({ consoleShape: s }), getBg: () => viewRef.current.consoleBg ?? "glow", setBg: (m) => persistView({ consoleBg: m }), - getPhoto: () => { + getPhoto: () => photoStore.load().then((blob) => { + if (blob) return URL.createObjectURL(blob); try { return localStorage.getItem("dsh.worktable.consoleBgPhoto.v1") ?? ""; } catch { return ""; } - }, - setPhoto: (dataUrl) => { + }).catch(() => { try { - localStorage.setItem("dsh.worktable.consoleBgPhoto.v1", dataUrl); + return localStorage.getItem("dsh.worktable.consoleBgPhoto.v1") ?? ""; } catch { + return ""; + } + }), + setPhoto: async (blob) => { + try { + await photoStore.save(blob); + try { + localStorage.removeItem("dsh.worktable.consoleBgPhoto.v1"); + } catch { + } + } catch { + try { + const dataUrl = await new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(String(fr.result)); + fr.onerror = () => reject(fr.error); + fr.readAsDataURL(blob); + }); + localStorage.setItem("dsh.worktable.consoleBgPhoto.v1", dataUrl); + } catch { + } } }, getPlainHsl: () => viewRef.current.consoleBgPlainHsl ?? { h: 220, s: 31, l: 6 }, diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 85df573..8fe2754 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -3,6 +3,7 @@ import { css } from './styles' import { NS, zh, en, type WorktableKey } from './locales' import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil' import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split' +import { photoStore } from './photoStore' /** * dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。 @@ -1355,8 +1356,32 @@ function WorktableSection(props: any) { setShape: (s: 'square' | 'circle') => persistView({ consoleShape: s }), getBg: () => viewRef.current.consoleBg ?? 'glow', setBg: (m: 'plain' | 'glow' | 'photo') => persistView({ consoleBg: m }), - getPhoto: () => { try { return localStorage.getItem('dsh.worktable.consoleBgPhoto.v1') ?? '' } catch { return '' } }, - setPhoto: (dataUrl: string) => { try { localStorage.setItem('dsh.worktable.consoleBgPhoto.v1', dataUrl) } catch {} }, + getPhoto: () => photoStore.load() + .then((blob) => { + if (blob) return URL.createObjectURL(blob) + try { return localStorage.getItem('dsh.worktable.consoleBgPhoto.v1') ?? '' } catch { return '' } + }) + .catch(() => { + try { return localStorage.getItem('dsh.worktable.consoleBgPhoto.v1') ?? '' } catch { return '' } + }), + setPhoto: async (blob: Blob) => { + try { + await photoStore.save(blob) + // 成功存原图后清掉旧的压缩图键,避免回退时读到旧图 + try { localStorage.removeItem('dsh.worktable.consoleBgPhoto.v1') } catch {} + } catch { + // IndexedDB 不可用(隐私模式等)→ 兜底:原图 dataURL 存旧键,超限则静默 + try { + const dataUrl = await new Promise((resolve, reject) => { + const fr = new FileReader() + fr.onload = () => resolve(String(fr.result)) + fr.onerror = () => reject(fr.error) + fr.readAsDataURL(blob) + }) + localStorage.setItem('dsh.worktable.consoleBgPhoto.v1', dataUrl) + } catch {} + } + }, getPlainHsl: () => viewRef.current.consoleBgPlainHsl ?? { h: 220, s: 31, l: 6 }, setPlainHsl: (v: { h: number; s: number; l: number }) => persistView({ consoleBgPlainHsl: { ...v } }), getGlowHsl: () => viewRef.current.consoleBgGlowHsl ?? { h: 0, s: 100, l: 100 }, diff --git a/01_content/src/client/photoStore.ts b/01_content/src/client/photoStore.ts new file mode 100644 index 0000000..8d3b4a2 --- /dev/null +++ b/01_content/src/client/photoStore.ts @@ -0,0 +1,40 @@ +/** dsh-worktable 控制室背景原图存取:IndexedDB 存原始 Blob,全分辨率显示(零压缩)。 */ +const DB_NAME = 'dsh-worktable' +const STORE = 'consoleBgPhoto' +const KEY = 'original' + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE) + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +export const photoStore = { + /** 保存原始图片(原样存,不重新编码) */ + async save(blob: Blob): Promise { + const db = await openDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite') + tx.objectStore(STORE).put(blob, KEY) + tx.oncomplete = () => { db.close(); resolve() } + tx.onerror = () => { db.close(); reject(tx.error) } + tx.onabort = () => { db.close(); reject(tx.error) } + }) + }, + /** 读取原始图片;无记录返回 null */ + async load(): Promise { + const db = await openDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly') + const req = tx.objectStore(STORE).get(KEY) + req.onsuccess = () => { const v = req.result; db.close(); resolve(v instanceof Blob ? v : null) } + req.onerror = () => { db.close(); reject(req.error) } + }) + }, +} diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 8eb17f0..6e1eab6 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -247,8 +247,8 @@ type SplitEnv = { setShape: (s: 'square' | 'circle') => void getBg: () => 'plain' | 'glow' | 'photo' setBg: (m: 'plain' | 'glow' | 'photo') => void - getPhoto: () => string - setPhoto: (dataUrl: string) => void + getPhoto: () => Promise + setPhoto: (blob: Blob) => Promise getPlainHsl: () => { h: number; s: number; l: number } setPlainHsl: (v: { h: number; s: number; l: number }) => void getGlowHsl: () => { h: number; s: number; l: number } @@ -1037,7 +1037,8 @@ function ConsolePane() { const [cols, setColsState] = useState(() => splitEnv?.console?.getCols?.() ?? 3) const [shape, setShapeState] = useState<'square' | 'circle'>(() => splitEnv?.console?.getShape?.() ?? 'square') const [bg, setBgState] = useState<'plain' | 'glow' | 'photo'>(() => splitEnv?.console?.getBg?.() ?? 'glow') - const [bgPhoto, setBgPhoto] = useState(() => splitEnv?.console?.getPhoto?.() ?? '') + const [bgPhoto, setBgPhoto] = useState('') + const photoUrlRef = useRef('') const [openMenu, setOpenMenu] = useState(null) const [bgEdit, setBgEdit] = useState<'plain' | 'glow' | null>(null) const [plainHsl, setPlainHslState] = useState<{ h: number; s: number; l: number }>(() => splitEnv?.console?.getPlainHsl?.() ?? { h: 220, s: 31, l: 6 }) @@ -1064,6 +1065,16 @@ function ConsolePane() { setShapeState(s) splitEnv?.console?.setShape?.(s) } + /** 原图上图:原始 Blob 存 IndexedDB,显示用对象 URL(零压缩全分辨率) */ + const onFilePhoto = async (file: Blob) => { + try { await splitEnv?.console?.setPhoto?.(file) } catch {} + const url = URL.createObjectURL(file) + if (photoUrlRef.current) { try { URL.revokeObjectURL(photoUrlRef.current) } catch {} } + photoUrlRef.current = url + setBgPhoto(url) + setBgState('photo') + splitEnv?.console?.setBg?.('photo') + } const onBg = (m: 'plain' | 'glow' | 'photo') => { if (m === 'photo') { const input = document.createElement('input') @@ -1072,31 +1083,7 @@ function ConsolePane() { input.onchange = () => { const file = input.files?.[0] if (!file) return - const url = URL.createObjectURL(file) - const img = new Image() - img.onload = () => { - const maxDim = 2400 - const scale = Math.min(1, maxDim / Math.max(img.width, img.height)) - const w = Math.max(1, Math.round(img.width * scale)) - const h = Math.max(1, Math.round(img.height * scale)) - const canvas = document.createElement('canvas') - canvas.width = w - canvas.height = h - const ctx = canvas.getContext('2d') - if (!ctx) { URL.revokeObjectURL(url); return } - // 高清重采样:大图缩到 2400px 上限,WebP 0.9(不支持时回退 JPEG 0.9) - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = 'high' - ctx.drawImage(img, 0, 0, w, h) - const webp = canvas.toDataURL('image/webp', 0.9) - const dataUrl = webp.indexOf('data:image/webp') === 0 ? webp : canvas.toDataURL('image/jpeg', 0.9) - URL.revokeObjectURL(url) - setBgPhoto(dataUrl) - setBgState('photo') - splitEnv?.console?.setBg?.('photo') - splitEnv?.console?.setPhoto?.(dataUrl) - } - img.src = url + onFilePhoto(file) } input.click() return @@ -1176,6 +1163,20 @@ function ConsolePane() { useEffect(() => { splitEnv?.console?.refreshPreviews?.() }, []) + // 背景原图:IndexedDB 异步加载(对象 URL 全分辨率;无记录时回退旧压缩图) + useEffect(() => { + let alive = true + splitEnv?.console?.getPhoto?.().then((url) => { + if (alive && url && url !== photoUrlRef.current) { + if (photoUrlRef.current) { try { URL.revokeObjectURL(photoUrlRef.current) } catch {} } + photoUrlRef.current = url + setBgPhoto(url) + } + }).catch(() => {}) + return () => { alive = false } + }, []) + // 面板卸载时释放对象 URL + useEffect(() => () => { if (photoUrlRef.current) { try { URL.revokeObjectURL(photoUrlRef.current) } catch {} } }, []) // 运行时长的分钟级刷新:每秒重渲染一次(仅控制室开着时存在) useEffect(() => { const iv = window.setInterval(() => setNow(Date.now()), 1000)