fix(images): 私有图片支持 Bearer 鉴权,画布节点改为带鉴权 blob 加载

This commit is contained in:
2026-10-12 13:54:34 +08:00
parent c559e712b6
commit 256eeb3234
5 changed files with 84 additions and 45 deletions
+16
View File
@@ -60,6 +60,22 @@ export function generatedUrl(name) {
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
}
/** Load a private image (thumbnail, falling back to the original) using the
* same Bearer token the SPA already has. Returns a browser blob: URL so the
* component can revoke it later. Throws on real failure. */
export async function loadImageBlob(name, signal) {
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
let u = thumbUrl(name)
let r = await fetch(u, { headers, credentials: 'include', signal })
if (!r.ok || r.status === 404) {
u = generatedUrl(name)
r = await fetch(u, { headers, credentials: 'include', signal })
}
if (!r.ok) throw new Error(`图片加载失败 (${r.status})`)
const blob = await r.blob()
return { url: URL.createObjectURL(blob), status: r.status }
}
/** Small thumbnail URL for list views. The server falls back to the original
* when no thumbnail object exists (old images), so this is always safe. */
export function thumbUrl(name) {
@@ -2,8 +2,8 @@
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
// 这里只负责节点自身内容:image / note / gen(生成中占位)。
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
import { computed, ref, onMounted, watch } from 'vue'
import { generatedUrl, thumbUrl } from '../../api'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { generatedUrl, loadImageBlob, thumbUrl } from '../../api'
const props = defineProps({
node: { type: Object, required: true },
@@ -17,14 +17,38 @@ const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
const imgLoaded = ref(false)
const imgBroken = ref(false)
const imgStatus = ref('')
const imgThumb = computed(() => thumbUrl(props.node.src))
const imgBlob = ref('')
const imgOriginal = computed(() => generatedUrl(props.node.src))
const imgOriginalAbs = computed(() => {
try { return new URL(imgOriginal.value, window.location.href).href } catch { return imgOriginal.value }
})
const noteEl = ref(null)
const editing = ref(false)
let imgController = null
async function loadSrc() {
if (props.node.type !== 'image' || !props.node.src) return
imgController?.abort()
imgController = new AbortController()
imgLoaded.value = false
imgBroken.value = false
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
try {
const { url } = await loadImageBlob(props.node.src, imgController.signal)
imgBlob.value = url
} catch (e) {
imgBroken.value = true
const msg = e?.message || ''
const m = msg.match(/\((\d+)\)/)
imgStatus.value = m ? m[1] : '失败'
imgLoaded.value = true
}
}
onMounted(() => loadSrc())
onUnmounted(() => {
imgController?.abort()
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
})
watch(() => props.node.src, loadSrc)
onMounted(() => {
// 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || ''
@@ -41,14 +65,6 @@ watch(
},
)
watch(
() => props.node.src,
() => {
imgLoaded.value = false
imgBroken.value = false
},
)
function onImgLoad(e) {
imgLoaded.value = true
imgBroken.value = false
@@ -64,19 +80,6 @@ function onImgLoad(e) {
}
}
function onImgError(e) {
if (e.target.src !== imgOriginalAbs.value) {
e.target.src = imgOriginal.value
return
}
imgLoaded.value = true
imgBroken.value = true
imgStatus.value = ''
fetch(imgOriginal.value, { credentials: 'include' })
.then((r) => { imgStatus.value = String(r.status) })
.catch(() => { imgStatus.value = '网络错误' })
}
function startEdit() {
if (props.node.type !== 'note') return
editing.value = true
@@ -149,15 +152,13 @@ defineExpose({ startEdit, commitNote })
<!-- 图片节点 -->
<template v-if="node.type === 'image'">
<img
v-if="!imgBroken"
v-if="!imgBroken && imgBlob"
class="cv-node__img"
:src="imgThumb"
loading="lazy"
:src="imgBlob"
decoding="async"
draggable="false"
alt=""
@load="onImgLoad"
@error="onImgError"
>
<div v-else class="cv-node__missing">图片不可用<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>