feat(canvas): 节点工具条精简为+参考图/删除,预览与图库改blob加载,参考图自动连线
This commit is contained in:
+21
-6
@@ -60,10 +60,9 @@ export function generatedUrl(name) {
|
|||||||
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
|
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a private image (thumbnail, falling back to the original) using the
|
/** Fetch a private image (thumbnail first, original as fallback) with the
|
||||||
* same Bearer token the SPA already has. Returns a browser blob: URL so the
|
* SPA's Bearer token. Returns { blob, status } — throws on real failure. */
|
||||||
* component can revoke it later. Throws on real failure. */
|
async function fetchImageBlob(name, signal) {
|
||||||
export async function loadImageBlob(name, signal) {
|
|
||||||
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
|
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
|
||||||
let u = thumbUrl(name)
|
let u = thumbUrl(name)
|
||||||
let r = await fetch(u, { headers, credentials: 'include', signal })
|
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 })
|
r = await fetch(u, { headers, credentials: 'include', signal })
|
||||||
}
|
}
|
||||||
if (!r.ok) throw new Error(`图片加载失败 (${r.status})`)
|
if (!r.ok) throw new Error(`图片加载失败 (${r.status})`)
|
||||||
const blob = await r.blob()
|
return { blob: await r.blob(), status: r.status }
|
||||||
return { url: URL.createObjectURL(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
|
/** Small thumbnail URL for list views. The server falls back to the original
|
||||||
|
|||||||
@@ -156,9 +156,7 @@ defineExpose({ startEdit, commitNote })
|
|||||||
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
||||||
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-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>
|
<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 v-if="node.type === 'image'" title="添加为参考图" @click.stop="emit('node-action', { type: 'ref' })">+</button>
|
||||||
<button title="复制 (Ctrl+D)" @click.stop="emit('node-action', { type: 'duplicate' })">⧉</button>
|
|
||||||
<button title="置顶" @click.stop="emit('node-action', { type: 'front' })">⤒</button>
|
|
||||||
<button class="cv-tools__del" title="删除" @click.stop="emit('node-action', { type: 'delete' })">✕</button>
|
<button class="cv-tools__del" title="删除" @click.stop="emit('node-action', { type: 'delete' })">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cv-node__clip">
|
<div class="cv-node__clip">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 交互细节全部在 CanvasStage;这里只做数据编排。
|
// 交互细节全部在 CanvasStage;这里只做数据编排。
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { api, generatedUrl, jsonBody, thumbUrl } from '../api'
|
import { api, jsonBody, loadImageBlob, loadImageData } from '../api'
|
||||||
import { auth } from '../auth'
|
import { auth } from '../auth'
|
||||||
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, rectsIntersect, DEFAULT_SCALE } from '../utils/canvas'
|
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, rectsIntersect, DEFAULT_SCALE } from '../utils/canvas'
|
||||||
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
||||||
@@ -24,6 +24,18 @@ const scalePct = ref(100)
|
|||||||
// ---- 选择 / 预览 ----
|
// ---- 选择 / 预览 ----
|
||||||
const selectionIds = ref([])
|
const selectionIds = ref([])
|
||||||
const previewSrc = 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([])
|
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() {
|
async function openPicker() {
|
||||||
pickerOpen.value = true
|
pickerOpen.value = true
|
||||||
if (pickerItems.value.length) return
|
if (pickerItems.value.length) return
|
||||||
@@ -484,6 +506,7 @@ async function openPicker() {
|
|||||||
pickerItems.value = (r.data?.data || []).filter((f) =>
|
pickerItems.value = (r.data?.data || []).filter((f) =>
|
||||||
/\.(png|jpe?g|webp|gif)$/i.test(f.name || ''),
|
/\.(png|jpe?g|webp|gif)$/i.test(f.name || ''),
|
||||||
)
|
)
|
||||||
|
pickerItems.value.forEach((f) => ensurePickerThumb(f.name))
|
||||||
pickerLoading.value = false
|
pickerLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +520,7 @@ async function uploadImageFile(file, at = null) {
|
|||||||
const r = await api('/my-images', { method: 'POST', body: fd })
|
const r = await api('/my-images', { method: 'POST', body: fd })
|
||||||
if (r.ok && r.data?.name) {
|
if (r.ok && r.data?.name) {
|
||||||
pickerItems.value.unshift({ name: r.data.name })
|
pickerItems.value.unshift({ name: r.data.name })
|
||||||
|
ensurePickerThumb(r.data.name)
|
||||||
addImageNode(r.data.name, '', at)
|
addImageNode(r.data.name, '', at)
|
||||||
} else {
|
} else {
|
||||||
genMsg.value = r.data?.detail || `上传失败 (${r.status})`
|
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) })
|
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) {
|
function onRefPicked(e) {
|
||||||
const files = [...(e.target.files || [])]
|
const files = [...(e.target.files || [])]
|
||||||
e.target.value = ''
|
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 n = gen.kind === 'image' ? Math.max(1, Math.min(4, Number(gen.count) || 1)) : 1
|
||||||
const size = ratioToSize(gen.ratio || ratios.value[0])
|
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 x
|
||||||
let y
|
let y
|
||||||
@@ -623,7 +672,10 @@ async function generate() {
|
|||||||
})
|
})
|
||||||
doc.nodes.push(node)
|
doc.nodes.push(node)
|
||||||
for (const s of sources) {
|
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)
|
created.push(node)
|
||||||
runGeneration(node, prompt)
|
runGeneration(node, prompt)
|
||||||
@@ -720,6 +772,8 @@ function onNodeAction({ type, id }) {
|
|||||||
if (!n) return
|
if (!n) return
|
||||||
if (type === 'preview') {
|
if (type === 'preview') {
|
||||||
if (n.type === 'image') previewSrc.value = n.src
|
if (n.type === 'image') previewSrc.value = n.src
|
||||||
|
} else if (type === 'ref') {
|
||||||
|
addNodeAsRef(n)
|
||||||
} else if (type === 'duplicate') {
|
} else if (type === 'duplicate') {
|
||||||
selectionIds.value = [id]
|
selectionIds.value = [id]
|
||||||
duplicateSelection()
|
duplicateSelection()
|
||||||
@@ -964,9 +1018,10 @@ const saveLabel = computed(() => ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片预览 -->
|
<!-- 图片预览(带鉴权 blob,不再依赖 cookie) -->
|
||||||
<div v-if="previewSrc" class="cv-lightbox" @click="previewSrc = ''">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 图片库:我的全部作品 + 第一格上传入口 -->
|
<!-- 图片库:我的全部作品 + 第一格上传入口 -->
|
||||||
@@ -991,8 +1046,7 @@ const saveLabel = computed(() => ({
|
|||||||
<img
|
<img
|
||||||
v-for="f in pickerItems"
|
v-for="f in pickerItems"
|
||||||
:key="f.name"
|
:key="f.name"
|
||||||
:src="thumbUrl(f.name)"
|
:src="pickerUrls[f.name] || undefined"
|
||||||
loading="lazy"
|
|
||||||
alt=""
|
alt=""
|
||||||
@click="addImageNode(f.name); pickerOpen = false"
|
@click="addImageNode(f.name); pickerOpen = false"
|
||||||
>
|
>
|
||||||
@@ -1406,6 +1460,11 @@ const saveLabel = computed(() => ({
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
.cv-lightbox__loading {
|
||||||
|
color: rgb(255 255 255 / 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.cv-lightbox img {
|
.cv-lightbox img {
|
||||||
max-width: 92vw;
|
max-width: 92vw;
|
||||||
max-height: 92vh;
|
max-height: 92vh;
|
||||||
|
|||||||
Reference in New Issue
Block a user