feat(canvas): 生成面板改为底部居中大输入框,参考图改为上传式

This commit is contained in:
2026-10-12 14:06:04 +08:00
parent 23bef52290
commit 2137f9565b
2 changed files with 241 additions and 116 deletions
@@ -824,9 +824,8 @@ defineExpose({
} }
.cv-hint { .cv-hint {
position: absolute; position: absolute;
left: 50%; left: 10px;
bottom: 10px; bottom: 10px;
transform: translateX(-50%);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--fg-2); color: var(--fg-2);
+222 -96
View File
@@ -1,7 +1,7 @@
<script setup> <script setup>
// 无限画布页面:文档管理(多画布/自动保存)、生成面板、撤销重做、快捷键。 // 无限画布页面:文档管理(多画布/自动保存)、生成面板、撤销重做、快捷键。
// 交互细节全部在 CanvasStage;这里只做数据编排。 // 交互细节全部在 CanvasStage;这里只做数据编排。
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } 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, generatedUrl, jsonBody, thumbUrl } from '../api'
import { auth } from '../auth' import { auth } from '../auth'
@@ -40,11 +40,6 @@ const resolutions = computed(() => model.value?.resolutions?.length
const maxRefs = computed(() => Math.min(4, Math.max( const maxRefs = computed(() => Math.min(4, Math.max(
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0, Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
))) )))
const selectedImages = computed(() =>
selectionIds.value
.map((id) => doc.nodes.find((n) => n.id === id))
.filter((n) => n && n.type === 'image'),
)
const selectedNotes = computed(() => const selectedNotes = computed(() =>
selectionIds.value selectionIds.value
.map((id) => doc.nodes.find((n) => n.id === id)) .map((id) => doc.nodes.find((n) => n.id === id))
@@ -498,18 +493,42 @@ function onPaste(e) {
// ================= 生成 ================= // ================= 生成 =================
async function refToBase64(name) { // ---- 参考图上传(与画图台一致:直接传文件,不再用选中节点) ----
const res = await fetch(generatedUrl(name)) const refImages = ref([]) // { name, dataUrl }
if (!res.ok) throw new Error(`参考图加载失败 (${res.status}),请重新选择图片`) const refInput = ref(null)
const blob = await res.blob() const MAX_REF_BYTES = 15 * 1024 * 1024
const dataUrl = await new Promise((resolve, reject) => {
function fileToDataUrl(f) {
return new Promise((resolve, reject) => {
const fr = new FileReader() const fr = new FileReader()
fr.onload = () => resolve(fr.result) fr.onload = () => resolve(fr.result)
fr.onerror = reject fr.onerror = reject
fr.readAsDataURL(blob) fr.readAsDataURL(f)
}) })
return dataUrl.replace(/^data:[^,]*,/, '')
} }
async function addRefFile(f) {
if (!f?.type?.startsWith('image/')) return
if (refImages.value.length >= maxRefs.value) {
genMsg.value = `参考图最多 ${maxRefs.value} 张`
return
}
if (f.size > MAX_REF_BYTES) {
genMsg.value = '单张参考图不能超过 15MB'
return
}
refImages.value.push({ name: f.name, dataUrl: await fileToDataUrl(f) })
}
function onRefPicked(e) {
const files = [...(e.target.files || [])]
e.target.value = ''
files.forEach(addRefFile)
}
function removeRef(i) {
refImages.value.splice(i, 1)
}
watch(maxRefs, (n) => {
if (refImages.value.length > n) refImages.value = refImages.value.slice(0, n)
})
async function generate() { async function generate() {
if (genBusy.value || !model.value) return if (genBusy.value || !model.value) return
@@ -518,19 +537,15 @@ async function generate() {
genMsg.value = '请输入提示词,或选中一个有内容的笔记节点' genMsg.value = '请输入提示词,或选中一个有内容的笔记节点'
return return
} }
const images = selectedImages.value.slice(0, maxRefs.value) if (refImages.value.length && !maxRefs.value) {
if (selectedImages.value.length && !maxRefs.value) { genMsg.value = '当前模型不支持参考图,请切换模型'
genMsg.value = '当前模型不支持参考图,请取消图片选择或切换模型'
return return
} }
genBusy.value = true genBusy.value = true
genMsg.value = '' genMsg.value = ''
const size = ratioToSize(gen.ratio || ratios.value[0]) const size = ratioToSize(gen.ratio || ratios.value[0])
const sources = [ const sources = [...selectedNotes.value]
...selectedNotes.value,
...images,
]
// 落点:参与源节点的包围盒右侧;无源则视口中心 // 落点:参与源节点的包围盒右侧;无源则视口中心
let x let x
let y let y
@@ -566,9 +581,8 @@ async function generate() {
model: gen.modelId, prompt, ratio: gen.ratio || ratios.value[0], model: gen.modelId, prompt, ratio: gen.ratio || ratios.value[0],
resolution: resolutions.value[0] || '1K', resolution: resolutions.value[0] || '1K',
} }
if (images.length) { if (refImages.value.length) {
const refs = await Promise.all(images.map((n) => refToBase64(n.src))) payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
payload.reference_images = refs
} }
const opts = jsonBody('POST', payload) const opts = jsonBody('POST', payload)
opts.headers['Idempotency-Key'] = node.id opts.headers['Idempotency-Key'] = node.id
@@ -813,22 +827,47 @@ const saveLabel = computed(() => ({
@node-action="onNodeAction" @node-action="onNodeAction"
@blank-dblclick="addNoteAt" @blank-dblclick="addNoteAt"
/> />
</div>
<!-- 生成面板 --> <!-- 底部居中的生成输入框(参考 vividai.run 的大输入框) -->
<aside class="cv-panel"> <div class="cv-dock">
<div class="cv-panel__title">生成到画布</div> <div class="cv-composer">
<label class="cv-label">模型</label> <div v-if="maxRefs > 0" class="cv-composer__refs">
<select v-if="imageModels.length" v-model="gen.modelId" class="cv-input" @change="gen.ratio = ratios[0]"> <div v-for="(r, i) in refImages" :key="i" class="cv-refthumb">
<img :src="r.dataUrl" :alt="r.name">
<button type="button" class="cv-refthumb__x" title="移除" @click="removeRef(i)">✕</button>
</div>
<button
v-if="refImages.length < maxRefs"
type="button"
class="cv-refadd"
title="上传参考图"
@click="refInput?.click()"
>
<span class="cv-refadd__icon">+</span>
<span>参考图</span>
<span class="cv-refadd__n">{{ refImages.length }}/{{ maxRefs }}</span>
</button>
<input ref="refInput" type="file" accept="image/*" multiple hidden @change="onRefPicked">
</div>
<textarea
v-model="gen.prompt"
class="cv-composer__ta"
rows="3"
placeholder="描述你要生成的画面…(留空则使用选中的笔记)"
/>
<div class="cv-composer__bar">
<select
v-if="imageModels.length"
v-model="gen.modelId"
class="cv-composer__sel"
title="生成模型"
@change="gen.ratio = ratios[0]"
>
<option v-for="m in imageModels" :key="m.id" :value="m.id"> <option v-for="m in imageModels" :key="m.id" :value="m.id">
{{ m.alias || m.name || m.id }} {{ m.alias || m.name || m.id }}
</option> </option>
</select> </select>
<div v-else class="cv-nomodels"> <div v-else class="cv-nomodels">⚠ 还没有可用的图像模型,请到管理后台添加</div>
⚠ 站点还没有可用的图像模型。请管理员到「管理后台 → 模型管理」添加并启用模型后,这里就能选择了。
</div>
<label class="cv-label">比例</label>
<div class="cv-ratios"> <div class="cv-ratios">
<button <button
v-for="r in ratios" v-for="r in ratios"
@@ -839,29 +878,14 @@ const saveLabel = computed(() => ({
@click="gen.ratio = r" @click="gen.ratio = r"
>{{ r }}</button> >{{ r }}</button>
</div> </div>
<label class="cv-label">提示词(留空则使用选中的笔记)</label>
<textarea
v-model="gen.prompt"
class="cv-input cv-textarea"
rows="4"
placeholder="描述你要生成的画面…"
/>
<div class="cv-refs">
参考图:选中画布上的图片节点({{ selectedImages.length }}/{{ maxRefs }})
</div>
<button class="cv-gen" :disabled="genBusy || !model" @click="generate"> <button class="cv-gen" :disabled="genBusy || !model" @click="generate">
{{ genBusy ? '生成中…' : '生成' }} {{ genBusy ? '生成中…' : '生成' }}
</button> </button>
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
<div class="cv-tips">
双击空白建笔记 · 依次点击两个连接点连线,再点起点/空白/Esc 取消 ·
选中后右下角缩放 · Ctrl+D 复制 · 本地图片直接拖入或 Ctrl+V 粘贴 · 按 1 适应全部内容
</div> </div>
</aside> </div>
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
</div>
</div>
</div> </div>
<!-- 图片预览 --> <!-- 图片预览 -->
@@ -1061,41 +1085,17 @@ const saveLabel = computed(() => ({
color: #dc2626; color: #dc2626;
} }
/* 右侧生成面板 = 玻璃侧栏 */ /* 底部居中的生成输入框 —— 悬浮在画布上 */
.cv-panel { .cv-dock {
width: 264px; position: absolute;
flex-shrink: 0; left: 50%;
border-left: 1px solid var(--ink-soft); bottom: 14px;
background: var(--surface); transform: translateX(-50%);
-webkit-backdrop-filter: blur(14px); width: min(720px, calc(100% - 28px));
backdrop-filter: blur(14px); z-index: 8;
padding: 16px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 9px; gap: 6px;
overflow-y: auto;
}
.cv-panel__title {
font-size: 15px;
font-weight: 800;
color: var(--fg);
display: inline-block;
background: linear-gradient(transparent 55%, var(--m-yellow) 55%);
align-self: flex-start;
padding: 0 2px;
}
.cv-label {
font-size: 11px;
font-weight: 700;
color: var(--fg-3);
margin-top: 4px;
}
.cv-input {
width: 100%;
}
.cv-textarea {
resize: vertical;
min-height: 72px;
} }
.cv-ratios { .cv-ratios {
display: flex; display: flex;
@@ -1123,13 +1123,134 @@ const saveLabel = computed(() => ({
border-color: transparent; border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow); box-shadow: 0 4px 14px var(--accent-glow);
} }
.cv-refs { /* 生成输入框:参考图缩略图在上,文本框居中,控制条在下 */
font-size: 11px; .cv-composer {
width: 100%;
border: 1px solid var(--glass-border);
border-radius: 20px;
background: var(--surface);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
box-shadow: var(--glass-shadow-lg);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
overflow: hidden;
}
.cv-composer:focus-within {
border-color: rgb(124 92 252 / 0.6);
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(124 92 252 / 0.18);
}
.cv-composer__refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 10px 10px 0;
}
.cv-composer__refs:empty,
.cv-composer__refs:not(:has(> *)) {
padding-top: 0;
}
.cv-refthumb {
position: relative;
width: 52px;
height: 52px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--ink-soft);
background: var(--surface-2);
}
.cv-refthumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cv-refthumb__x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
display: grid;
place-items: center;
font-size: 9px;
color: #fff;
background: rgb(24 21 36 / 0.65);
cursor: pointer;
}
.cv-refthumb__x:hover {
background: #ef4444;
}
.cv-refadd {
width: 52px;
height: 52px;
border-radius: 10px;
border: 2px dashed rgb(124 92 252 / 0.45);
background: transparent;
color: var(--m-orange);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.13s ease;
}
.cv-refadd:hover {
border-color: var(--m-orange);
background: rgb(124 92 252 / 0.08);
transform: translateY(-1px);
}
.cv-refadd__icon {
font-size: 15px;
line-height: 1;
}
.cv-refadd__n {
font-size: 9px;
font-weight: 600; font-weight: 600;
color: var(--fg-2); color: var(--fg-3);
border: 2px dashed var(--ink-soft); }
.cv-composer__ta {
width: 100%;
min-height: 56px;
padding: 10px 14px;
border: none;
background: transparent;
color: var(--fg);
font-size: 13px;
line-height: 1.55;
resize: none;
outline: none;
display: block;
}
.cv-composer__bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 8px 12px;
border-top: 1px solid var(--ink-soft);
}
.cv-composer__sel {
padding: 5px 9px;
border-radius: 9px; border-radius: 9px;
padding: 8px 10px; border: 1px solid var(--ink-soft);
background: transparent;
color: var(--fg);
font-size: 12px;
font-weight: 600;
outline: none;
max-width: 200px;
}
.cv-composer__sel:focus {
border-color: rgb(124 92 252 / 0.6);
}
.cv-composer__bar .cv-gen {
margin-left: auto;
padding: 7px 24px;
} }
.cv-gen { .cv-gen {
padding: 10px; padding: 10px;
@@ -1160,6 +1281,14 @@ const saveLabel = computed(() => ({
font-size: 11px; font-size: 11px;
color: var(--fg-2); color: var(--fg-2);
word-break: break-all; word-break: break-all;
text-align: center;
background: var(--surface);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border-radius: 999px;
padding: 3px 14px;
align-self: center;
box-shadow: var(--glass-shadow);
} }
.cv-nomodels { .cv-nomodels {
font-size: 11px; font-size: 11px;
@@ -1171,11 +1300,8 @@ const saveLabel = computed(() => ({
background: rgb(245 158 11 / 0.12); background: rgb(245 158 11 / 0.12);
padding: 8px 10px; padding: 8px 10px;
} }
.cv-tips { .cv-dock .cv-nomodels {
font-size: 11px; flex: 1;
color: var(--fg-faint);
line-height: 1.7;
margin-top: auto;
} }
.cv-lightbox { .cv-lightbox {