feat(console): 背景照片改存 IndexedDB 原始图片,显示零压缩全分辨率(旧压缩图仅回退)

This commit is contained in:
Aisland
2026-09-01 12:51:27 +08:00
parent 9aaca17d3a
commit 0145f1883c
4 changed files with 223 additions and 62 deletions
+127 -32
View File
@@ -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 },
+27 -2
View File
@@ -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<string>((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 },
+40
View File
@@ -0,0 +1,40 @@
/** dsh-worktable 控制室背景原图存取:IndexedDB 存原始 Blob,全分辨率显示(零压缩)。 */
const DB_NAME = 'dsh-worktable'
const STORE = 'consoleBgPhoto'
const KEY = 'original'
function openDb(): Promise<IDBDatabase> {
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<void> {
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<Blob | null> {
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) }
})
},
}
+29 -28
View File
@@ -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<string>
setPhoto: (blob: Blob) => Promise<void>
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<number>(() => 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<string>(() => splitEnv?.console?.getPhoto?.() ?? '')
const [bgPhoto, setBgPhoto] = useState<string>('')
const photoUrlRef = useRef<string>('')
const [openMenu, setOpenMenu] = useState<string | null>(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)