fix(canvas): 连线改用主题色并修复图片回退

This commit is contained in:
2026-10-12 13:30:12 +08:00
parent 2e5a1e2e72
commit 5a0cbeb429
3 changed files with 59 additions and 10 deletions
+13 -3
View File
@@ -47,13 +47,23 @@ export async function fetchHealth() {
return r.json() return r.json()
} }
/** Absolute URL for a generated artifact (works in dev via proxy too). */ /** Absolute URL for a generated artifact (works in dev via proxy too).
* Older canvas documents may contain "/images/..." or a full URL instead of
* the storage key; normalize all three forms instead of double-prefixing. */
export function generatedUrl(name) { export function generatedUrl(name) {
return `${BASE}/images/${name}` const s = String(name || '').trim()
if (!s) return ''
const marker = '/images/'
const i = s.indexOf(marker)
if (i >= 0) return /^https?:\/\//i.test(s) ? s : `${BASE}${s.slice(i)}`
if (/^https?:\/\//i.test(s)) return s
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
} }
/** Small thumbnail URL for list views. The server falls back to the original /** 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. */ * when no thumbnail object exists (old images), so this is always safe. */
export function thumbUrl(name) { export function thumbUrl(name) {
return `${BASE}/images/${name}.thumb.jpg` const url = generatedUrl(name)
if (!url || url.endsWith('.thumb.jpg') || !url.includes('/images/')) return url
return `${url}.thumb.jpg`
} }
@@ -2,8 +2,8 @@
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放, // 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
// 这里只负责节点自身内容:image / note / gen(生成中占位)。 // 这里只负责节点自身内容:image / note / gen(生成中占位)。
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。 // 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
import { ref, onMounted, watch } from 'vue' import { computed, ref, onMounted, watch } from 'vue'
import { thumbUrl } from '../../api' import { generatedUrl, thumbUrl } from '../../api'
const props = defineProps({ const props = defineProps({
node: { type: Object, required: true }, node: { type: Object, required: true },
@@ -15,6 +15,12 @@ const portSides = ['top', 'right', 'bottom', 'left']
const portLabels = { top: '上', right: '右', bottom: '下', left: '左' } const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
const imgLoaded = ref(false) const imgLoaded = ref(false)
const imgBroken = ref(false)
const imgThumb = computed(() => thumbUrl(props.node.src))
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 noteEl = ref(null)
const editing = ref(false) const editing = ref(false)
@@ -34,8 +40,17 @@ watch(
}, },
) )
watch(
() => props.node.src,
() => {
imgLoaded.value = false
imgBroken.value = false
},
)
function onImgLoad(e) { function onImgLoad(e) {
imgLoaded.value = true imgLoaded.value = true
imgBroken.value = false
const img = e.target const img = e.target
// 按真实长宽比校正节点尺寸(以长边 512 归一,保持画布视觉密度一致)。 // 按真实长宽比校正节点尺寸(以长边 512 归一,保持画布视觉密度一致)。
if (img.naturalWidth && img.naturalHeight) { if (img.naturalWidth && img.naturalHeight) {
@@ -48,6 +63,15 @@ function onImgLoad(e) {
} }
} }
function onImgError(e) {
if (e.target.src !== imgOriginalAbs.value) {
e.target.src = imgOriginal.value
return
}
imgLoaded.value = true
imgBroken.value = true
}
function startEdit() { function startEdit() {
if (props.node.type !== 'note') return if (props.node.type !== 'note') return
editing.value = true editing.value = true
@@ -119,15 +143,17 @@ defineExpose({ startEdit, commitNote })
<!-- 图片节点 --> <!-- 图片节点 -->
<template v-if="node.type === 'image'"> <template v-if="node.type === 'image'">
<img <img
v-if="!imgBroken"
class="cv-node__img" class="cv-node__img"
:src="thumbUrl(node.src)" :src="imgThumb"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
draggable="false" draggable="false"
alt="" alt=""
@load="onImgLoad" @load="onImgLoad"
@error="imgLoaded = true" @error="onImgError"
> >
<div v-else class="cv-node__missing">图片不可用</div>
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div> <div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
</template> </template>
@@ -186,6 +212,16 @@ defineExpose({ startEdit, commitNote })
display: block; display: block;
background: var(--surface-2); background: var(--surface-2);
} }
.cv-node__missing {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--fg-3);
font-size: 13px;
font-weight: 600;
background: var(--surface-2);
}
.cv-node__cap { .cv-node__cap {
position: absolute; position: absolute;
@@ -610,17 +610,20 @@ defineExpose({
} }
.cv-edge { .cv-edge {
fill: none; fill: none;
stroke: var(--ink-soft); stroke: var(--m-orange-2);
stroke-width: 2; stroke-width: 2.4;
filter: drop-shadow(0 0 5px rgb(34 211 238 / 0.35));
} }
.cv-edge--hot { .cv-edge--hot {
stroke: var(--m-orange); stroke: var(--m-orange);
stroke-width: 2.5; stroke-width: 2.8;
filter: drop-shadow(0 0 7px var(--accent-glow));
} }
.cv-edge--sel { .cv-edge--sel {
stroke: var(--m-orange); stroke: var(--m-orange);
stroke-width: 3.5; stroke-width: 3.5;
stroke-dasharray: 10 6; stroke-dasharray: 10 6;
filter: drop-shadow(0 0 8px var(--accent-glow));
} }
.cv-edge-hit { .cv-edge-hit {
fill: none; fill: none;