feat(s3): base64 内嵌图片纳入未上传检测与上传(用户指正)

初版把 data: 一律排除在「未上传」外——未落盘文档粘贴的图、导入
文档自带的图都是 data:image/...;base64 形态,批量/右键/状态栏全
检测不到。现 isUploadableImageSrc = 本地文件引用 ∪ base64 内嵌图:
base64 图走新命令 s3_upload_base64(总数 31 对账),前端剥前缀传
纯 base64、Rust 解码后与文件图共用同一条 PUT 通路;对象名按内容
哈希(FNV-1a)生成,同图同名幂等。未落盘文档粘贴图也挂上自动上传。
进度/报错展示名对 base64 图用其哈希名,不再漏出 base64 串。
验证:独立 crate 对模拟 S3 实测 base64 解码 PUT;vitest 136+2、构建通过。
This commit is contained in:
Muse
2026-11-15 09:16:18 +08:00
parent b3c22fd597
commit fb51276bc5
11 changed files with 156 additions and 27 deletions

View File

@@ -29,7 +29,7 @@ import {
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, type GitStatusInfo,
gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo,
} from './services/platform'
import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery'
@@ -37,7 +37,7 @@ import { appWindowClaims, appWindowId } from './services/windowClaims'
import { acquireGitSyncLease, gitStatusLabel, gitSyncCommitMessage, loadGitSyncSettings, releaseGitSyncLease, saveGitSyncSettings, type GitSyncSettings } from './services/gitSync'
import { GitSyncModal } from './components/GitSyncModal'
import { S3SettingsModal } from './components/S3SettingsModal'
import { DEFAULT_S3_SETTINGS, isS3Configured, isLocalImageSrc, resolveDocRelativePath, imageFileName, s3ConfigPayload, type S3Settings } from './services/s3'
import { DEFAULT_S3_SETTINGS, isS3Configured, isUploadableImageSrc, isDataImageSrc, parseDataImage, dataImageFileName, resolveDocRelativePath, imageFileName, imageDisplayName, s3ConfigPayload, type S3Settings } from './services/s3'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES, docThemeById } from './themes'
@@ -1175,47 +1175,58 @@ export default function App() {
const [s3Progress, setS3Progress] = useState<{ total: number; done: number; current: string; errors: string[]; finished: boolean } | null>(null)
const s3BusyRef = useRef(false)
/** 上传一张本地图片(docPath + 文档相对 src),返回 S3 公开地址。 */
const uploadLocalImage = useCallback(async (docPath: string, src: string): Promise<string> => {
const abs = resolveDocRelativePath(docPath, src)
return s3UploadImage(s3ConfigPayload(uiRef.current.s3), abs, imageFileName(src))
/**
* 上传一张未上传图片,返回 S3 公开地址:base64 内嵌图直接传字节(不需
* 文档落盘);本地文件图按 docPath + 相对 src 定位文件。
*/
const uploadImageSrc = useCallback(async (docPath: string | null, src: string): Promise<string> => {
const cfg = s3ConfigPayload(uiRef.current.s3)
const data = parseDataImage(src)
if (data) {
const name = dataImageFileName(src)
if (!name) throw new Error('无法识别内嵌图片格式')
return s3UploadBase64(cfg, data.base64, name)
}
if (!docPath) throw new Error('文档尚未保存,无法定位本地图片文件')
return s3UploadImage(cfg, resolveDocRelativePath(docPath, src), imageFileName(src))
}, [])
/** 右键单张上传:成功后把编辑器里的引用换成 S3 地址。 */
const runSingleImageUpload = useCallback(async (src: string) => {
const current = activeTab(stateRef.current)
if (!current?.path) { setError('请先保存文档,再上传图片。'); return }
if (!current) return
if (!isDataImageSrc(src) && !current.path) { setError('请先保存文档,再上传本地图片。'); return }
if (!isS3Configured(uiRef.current.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
flash('正在上传图片…')
try {
const url = await uploadLocalImage(current.path, src)
const url = await uploadImageSrc(current.path, src)
paneApisRef.current.get(current.id)?.replaceImageSrc(src, url)
flash('图片已上传到 S3,引用已更新')
} catch (e) { setError(e instanceof Error ? e.message : String(e)) }
}, [flash, uploadLocalImage])
}, [flash, uploadImageSrc])
/** 批量上传:扫描当前文档全部本地图片逐张上传回换,进度走 s3Progress 弹窗。 */
const runBatchImageUpload = async () => {
if (!tab?.path) { setError('请先保存文档,再批量上传图片。'); return }
if (!tab) return
if (sourceMode) { flash('源码模式下不能批量上传,请先切回即时渲染'); return }
if (!isS3Configured(ui.s3)) { setS3SettingsOpen(true); flash('先把 S3 配置填好,再上传图片'); return }
if (s3BusyRef.current) return
const api = paneApisRef.current.get(tab.id)
if (!api) return
const locals = [...new Set(api.listImages().filter(isLocalImageSrc))]
if (locals.length === 0) { flash('本文档没有未上传的本地图片'); return }
const locals = [...new Set(api.listImages().filter(isUploadableImageSrc))]
if (locals.length === 0) { flash('本文档没有未上传的图片'); return }
const docPath = tab.path
s3BusyRef.current = true
setS3Progress({ total: locals.length, done: 0, current: '', errors: [], finished: false })
const errors: string[] = []
let done = 0
for (const src of locals) {
setS3Progress((pr) => (pr ? { ...pr, current: imageFileName(src) } : pr))
setS3Progress((pr) => (pr ? { ...pr, current: imageDisplayName(src) } : pr))
try {
const url = await uploadLocalImage(docPath, src)
const url = await uploadImageSrc(docPath, src)
api.replaceImageSrc(src, url)
} catch (e) {
errors.push(`${imageFileName(src)}:${e instanceof Error ? e.message : String(e)}`)
errors.push(`${imageDisplayName(src)}:${e instanceof Error ? e.message : String(e)}`)
}
done += 1
setS3Progress((pr) => (pr ? { ...pr, done, errors: [...errors] } : pr))
@@ -1229,12 +1240,12 @@ export default function App() {
* 粘贴建节点是异步的,第一次替换可能扑空,900ms 后重试一次;仍扑空则
* 保留本地引用并提示可用批量上传同步,不静默。
*/
const autoUploadSwap = useCallback((tabId: string, docPath: string, relSrc: string) => {
const autoUploadSwap = useCallback((tabId: string, docPath: string | null, relSrc: string) => {
const settings = uiRef.current.s3
if (!settings.autoUpload || !isS3Configured(settings)) return
if (!isTauri() || !settings.autoUpload || !isS3Configured(settings)) return
void (async () => {
try {
const url = await s3UploadImage(s3ConfigPayload(settings), resolveDocRelativePath(docPath, relSrc), imageFileName(relSrc))
const url = await uploadImageSrc(docPath, relSrc)
let n = paneApisRef.current.get(tabId)?.replaceImageSrc(relSrc, url) ?? 0
if (n === 0) {
await new Promise((r) => setTimeout(r, 900))
@@ -1245,7 +1256,7 @@ export default function App() {
flash(`图片自动上传失败(已保留本地引用):${e instanceof Error ? e.message : String(e)}`)
}
})()
}, [flash])
}, [flash, uploadImageSrc])
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
const handleImageFile = useCallback(async (file: File): Promise<string> => {
@@ -1257,12 +1268,15 @@ export default function App() {
autoUploadSwap(current.id, current.path, rel)
return rel
}
return new Promise((resolve) => {
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => resolve(URL.createObjectURL(file))
reader.readAsDataURL(file)
})
// 未落盘文档的粘贴图是 base64 内嵌形态:开了自动上传同样传走并回换
if (current && dataUrl.startsWith('data:')) autoUploadSwap(current.id, current.path ?? null, dataUrl)
return dataUrl
}, [autoUploadSwap])
const insertImage = useCallback(async () => {
@@ -1508,7 +1522,7 @@ export default function App() {
const found = new Set<string>()
const re = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
let m: RegExpExecArray | null
while ((m = re.exec(tab.content))) if (isLocalImageSrc(m[1])) found.add(m[1])
while ((m = re.exec(tab.content))) if (isUploadableImageSrc(m[1])) found.add(m[1])
return found.size
}, [tab?.id, tab?.content, sourceMode])

View File

@@ -1,7 +1,7 @@
import { useRef } from 'react'
import { useDismiss } from '../hooks/useDismiss'
import { clampMenuPosition } from '../utils/menuPos'
import { isLocalImageSrc } from '../services/s3'
import { isUploadableImageSrc } from '../services/s3'
export type EditorMenuAction =
| 'cut' | 'copy' | 'paste' | 'selectAll'
@@ -29,7 +29,7 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
const ref = useRef<HTMLDivElement>(null)
useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
const showUpload = Boolean(imageSrc && isLocalImageSrc(imageSrc))
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项)
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0))
return (

View File

@@ -366,6 +366,11 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
return invoke<string>('s3_upload_image', { config, filePath, fileName })
}
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName })
}
/** 对存储桶发签名 HEAD 验连通与密钥,成功返回一句可读结论。 */
export async function s3TestConnection(config: S3ConfigPayload): Promise<string> {
return invoke<string>('s3_test_connection', { config })

View File

@@ -23,13 +23,53 @@ export function isS3Configured(s: S3Settings): boolean {
return Boolean(s.bucket.trim() && s.region.trim() && s.accessKey.trim() && s.secretKey.trim())
}
/** 本地图片 = 文档里的相对/本地引用;http(s)/data/blob 已经是网络或内嵌资源,不需要上传。 */
/** 本地图片 = 文档里的相对/本地文件引用;http(s)/blob 已经是网络资源,不需要上传。 */
export function isLocalImageSrc(src: string): boolean {
const t = src.trim()
if (!t) return false
return !/^(https?:|data:|blob:)/i.test(t)
}
/**
* 内嵌 base64 图片(data:image/...;base64,...):未落盘文档粘贴的图片、
* 导入文档自带的图片都是这种形态——同样算「未上传」,上传时直接传字节,
* 不需要本地文件(2026-11-15 用户指正:原先把 data: 一律排除在检测外)。
*/
export function parseDataImage(src: string): { mime: string; base64: string } | null {
const m = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(src.trim())
if (!m) return null
return { mime: m[1].toLowerCase(), base64: m[2].replace(/\s+/g, '') }
}
export function isDataImageSrc(src: string): boolean {
return parseDataImage(src) !== null
}
/** 需要上传的图片 = 本地文件引用 或 内嵌 base64 图片。 */
export function isUploadableImageSrc(src: string): boolean {
return isLocalImageSrc(src) || isDataImageSrc(src)
}
/**
* base64 图片的对象文件名:image-<内容哈希>.<扩展名>。
* 用内容哈希(FNV-1a)命名:同一张图重复上传同名覆盖、幂等;不同图不撞名。
*/
export function dataImageFileName(src: string): string | null {
const parsed = parseDataImage(src)
if (!parsed) return null
let ext = parsed.mime.split('/')[1] ?? 'png'
ext = ext.split('+')[0]
if (ext === 'jpeg') ext = 'jpg'
if (!/^[a-z0-9]+$/.test(ext)) ext = 'png'
let h = 0x811c9dc5
const b64 = parsed.base64
for (let i = 0; i < b64.length; i++) {
h ^= b64.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return `image-${(h >>> 0).toString(16).padStart(8, '0')}.${ext}`
}
/** 从 UiState.s3 里摘出传给 Rust 的部分(不含 autoUpload)。 */
export function s3ConfigPayload(s: S3Settings): S3ConfigPayload {
return {
@@ -68,6 +108,11 @@ function safeDecode(s: string): string {
try { return decodeURIComponent(s) } catch { return s }
}
/** 进度/报错里展示的图片名:base64 图用其对象名(哈希名),文件图用原文件名。 */
export function imageDisplayName(src: string): string {
return dataImageFileName(src) ?? imageFileName(src)
}
/** src 的文件名部分(对象键用它;保留原名便于在桶里辨认)。 */
export function imageFileName(src: string): string {
const clean = safeDecode(src).replace(/\\/g, '/')