feat(canvas): 节点工具条精简为+参考图/删除,预览与图库改blob加载,参考图自动连线
This commit is contained in:
+21
-6
@@ -60,10 +60,9 @@ 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) {
|
||||
/** Fetch a private image (thumbnail first, original as fallback) with the
|
||||
* SPA's Bearer token. Returns { blob, status } — throws on real failure. */
|
||||
async function fetchImageBlob(name, signal) {
|
||||
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
|
||||
let u = thumbUrl(name)
|
||||
let r = await fetch(u, { headers, credentials: 'include', signal })
|
||||
@@ -72,8 +71,24 @@ export async function loadImageBlob(name, signal) {
|
||||
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 }
|
||||
return { blob: await r.blob(), status: r.status }
|
||||
}
|
||||
|
||||
/** Same as fetchImageBlob but returns a revocable blob: URL for <img>/<video>. */
|
||||
export async function loadImageBlob(name, signal) {
|
||||
const { blob, status } = await fetchImageBlob(name, signal)
|
||||
return { url: URL.createObjectURL(blob), status }
|
||||
}
|
||||
|
||||
/** Same as fetchImageBlob but returns a data: URL (用于参考图提交/复制)。 */
|
||||
export async function loadImageData(name) {
|
||||
const { blob } = await fetchImageBlob(name)
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => resolve(fr.result)
|
||||
fr.onerror = reject
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** Small thumbnail URL for list views. The server falls back to the original
|
||||
|
||||
@@ -156,9 +156,7 @@ defineExpose({ startEdit, commitNote })
|
||||
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
||||
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
|
||||
<div v-if="selected && node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
|
||||
<button v-if="node.type === 'image'" title="预览" @click.stop="emit('node-action', { type: 'preview' })">⤢</button>
|
||||
<button title="复制 (Ctrl+D)" @click.stop="emit('node-action', { type: 'duplicate' })">⧉</button>
|
||||
<button title="置顶" @click.stop="emit('node-action', { type: 'front' })">⤒</button>
|
||||
<button v-if="node.type === 'image'" title="添加为参考图" @click.stop="emit('node-action', { type: 'ref' })">+</button>
|
||||
<button class="cv-tools__del" title="删除" @click.stop="emit('node-action', { type: 'delete' })">✕</button>
|
||||
</div>
|
||||
<div class="cv-node__clip">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 交互细节全部在 CanvasStage;这里只做数据编排。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { api, generatedUrl, jsonBody, thumbUrl } from '../api'
|
||||
import { api, jsonBody, loadImageBlob, loadImageData } from '../api'
|
||||
import { auth } from '../auth'
|
||||
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, rectsIntersect, DEFAULT_SCALE } from '../utils/canvas'
|
||||
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
||||
@@ -24,6 +24,18 @@ const scalePct = ref(100)
|
||||
// ---- 选择 / 预览 ----
|
||||
const selectionIds = ref([])
|
||||
const previewSrc = ref('')
|
||||
const previewUrl = ref('')
|
||||
watch(previewSrc, async (v) => {
|
||||
if (previewUrl.value) { URL.revokeObjectURL(previewUrl.value); previewUrl.value = '' }
|
||||
if (!v) return
|
||||
try {
|
||||
const { url } = await loadImageBlob(v)
|
||||
if (previewSrc.value === v) previewUrl.value = url
|
||||
} catch { /* 预览失败保持空白遮罩 */ }
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
})
|
||||
|
||||
// ---- 生成面板 ----
|
||||
const allModels = ref([])
|
||||
@@ -476,6 +488,16 @@ async function deleteCanvas() {
|
||||
|
||||
// ---- 我的图片 ----
|
||||
|
||||
const pickerUrls = reactive({}) // name -> blob URL(带鉴权加载)
|
||||
async function ensurePickerThumb(name) {
|
||||
if (name in pickerUrls) return
|
||||
try {
|
||||
const { url } = await loadImageBlob(name)
|
||||
pickerUrls[name] = url
|
||||
} catch {
|
||||
pickerUrls[name] = ''
|
||||
}
|
||||
}
|
||||
async function openPicker() {
|
||||
pickerOpen.value = true
|
||||
if (pickerItems.value.length) return
|
||||
@@ -484,6 +506,7 @@ async function openPicker() {
|
||||
pickerItems.value = (r.data?.data || []).filter((f) =>
|
||||
/\.(png|jpe?g|webp|gif)$/i.test(f.name || ''),
|
||||
)
|
||||
pickerItems.value.forEach((f) => ensurePickerThumb(f.name))
|
||||
pickerLoading.value = false
|
||||
}
|
||||
|
||||
@@ -497,6 +520,7 @@ async function uploadImageFile(file, at = null) {
|
||||
const r = await api('/my-images', { method: 'POST', body: fd })
|
||||
if (r.ok && r.data?.name) {
|
||||
pickerItems.value.unshift({ name: r.data.name })
|
||||
ensurePickerThumb(r.data.name)
|
||||
addImageNode(r.data.name, '', at)
|
||||
} else {
|
||||
genMsg.value = r.data?.detail || `上传失败 (${r.status})`
|
||||
@@ -568,6 +592,21 @@ async function addRefFile(f) {
|
||||
}
|
||||
refImages.value.push({ name: f.name, dataUrl: await fileToDataUrl(f) })
|
||||
}
|
||||
// 画布图片节点 → 参考图(记录 nodeId,生成时自动连边标明来源)
|
||||
async function addNodeAsRef(n) {
|
||||
if (!n || n.type !== 'image' || !n.src) return
|
||||
if (refImages.value.some((r) => r.nodeId === n.id)) return
|
||||
if (refImages.value.length >= maxRefs.value) {
|
||||
genMsg.value = `参考图最多 ${maxRefs.value} 张`
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dataUrl = await loadImageData(n.src)
|
||||
refImages.value.push({ name: n.src, dataUrl, nodeId: n.id })
|
||||
} catch (e) {
|
||||
genMsg.value = `参考图读取失败:${e.message || e}`
|
||||
}
|
||||
}
|
||||
function onRefPicked(e) {
|
||||
const files = [...(e.target.files || [])]
|
||||
e.target.value = ''
|
||||
@@ -595,7 +634,17 @@ async function generate() {
|
||||
|
||||
const n = gen.kind === 'image' ? Math.max(1, Math.min(4, Number(gen.count) || 1)) : 1
|
||||
const size = ratioToSize(gen.ratio || ratios.value[0])
|
||||
const sources = [...selectedNotes.value]
|
||||
// 生成来源:选中的笔记(提示词) + 通过 + 添加为参考图的画布节点,生成时自动连线
|
||||
const refNodes = refImages.value
|
||||
.filter((r) => r.nodeId)
|
||||
.map((r) => doc.nodes.find((n) => n.id === r.nodeId))
|
||||
.filter(Boolean)
|
||||
const seenSrc = new Set()
|
||||
const sources = [...selectedNotes.value, ...refNodes].filter((n) => {
|
||||
if (seenSrc.has(n.id)) return false
|
||||
seenSrc.add(n.id)
|
||||
return true
|
||||
})
|
||||
// 落点:参与源节点的包围盒右侧;无源则视口中心。多张时横向排开。
|
||||
let x
|
||||
let y
|
||||
@@ -623,7 +672,10 @@ async function generate() {
|
||||
})
|
||||
doc.nodes.push(node)
|
||||
for (const s of sources) {
|
||||
doc.edges.push({ id: newId('e'), from: s.id, to: node.id })
|
||||
// 同向已存在就跳过,避免重复生成时边越堆越多
|
||||
if (!doc.edges.some((e) => e.from === s.id && e.to === node.id)) {
|
||||
doc.edges.push({ id: newId('e'), from: s.id, to: node.id })
|
||||
}
|
||||
}
|
||||
created.push(node)
|
||||
runGeneration(node, prompt)
|
||||
@@ -720,6 +772,8 @@ function onNodeAction({ type, id }) {
|
||||
if (!n) return
|
||||
if (type === 'preview') {
|
||||
if (n.type === 'image') previewSrc.value = n.src
|
||||
} else if (type === 'ref') {
|
||||
addNodeAsRef(n)
|
||||
} else if (type === 'duplicate') {
|
||||
selectionIds.value = [id]
|
||||
duplicateSelection()
|
||||
@@ -964,9 +1018,10 @@ const saveLabel = computed(() => ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<!-- 图片预览(带鉴权 blob,不再依赖 cookie) -->
|
||||
<div v-if="previewSrc" class="cv-lightbox" @click="previewSrc = ''">
|
||||
<img :src="generatedUrl(previewSrc)" alt="" @click.stop>
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="" @click.stop>
|
||||
<span v-else class="cv-lightbox__loading">加载中…</span>
|
||||
</div>
|
||||
|
||||
<!-- 图片库:我的全部作品 + 第一格上传入口 -->
|
||||
@@ -991,8 +1046,7 @@ const saveLabel = computed(() => ({
|
||||
<img
|
||||
v-for="f in pickerItems"
|
||||
:key="f.name"
|
||||
:src="thumbUrl(f.name)"
|
||||
loading="lazy"
|
||||
:src="pickerUrls[f.name] || undefined"
|
||||
alt=""
|
||||
@click="addImageNode(f.name); pickerOpen = false"
|
||||
>
|
||||
@@ -1406,6 +1460,11 @@ const saveLabel = computed(() => ({
|
||||
z-index: 100;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.cv-lightbox__loading {
|
||||
color: rgb(255 255 255 / 0.8);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cv-lightbox img {
|
||||
max-width: 92vw;
|
||||
max-height: 92vh;
|
||||
|
||||
Reference in New Issue
Block a user