feat(canvas): 生成面板改为底部居中大输入框,参考图改为上传式
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
+240
-114
@@ -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,55 +827,65 @@ const saveLabel = computed(() => ({
|
|||||||
@node-action="onNodeAction"
|
@node-action="onNodeAction"
|
||||||
@blank-dblclick="addNoteAt"
|
@blank-dblclick="addNoteAt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 底部居中的生成输入框(参考 vividai.run 的大输入框) -->
|
||||||
|
<div class="cv-dock">
|
||||||
|
<div class="cv-composer">
|
||||||
|
<div v-if="maxRefs > 0" class="cv-composer__refs">
|
||||||
|
<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">
|
||||||
|
{{ m.alias || m.name || m.id }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-else class="cv-nomodels">⚠ 还没有可用的图像模型,请到管理后台添加</div>
|
||||||
|
<div class="cv-ratios">
|
||||||
|
<button
|
||||||
|
v-for="r in ratios"
|
||||||
|
:key="r"
|
||||||
|
type="button"
|
||||||
|
class="cv-chip"
|
||||||
|
:class="{ 'is-on': gen.ratio === r }"
|
||||||
|
@click="gen.ratio = r"
|
||||||
|
>{{ r }}</button>
|
||||||
|
</div>
|
||||||
|
<button class="cv-gen" :disabled="genBusy || !model" @click="generate">
|
||||||
|
{{ genBusy ? '生成中…' : '生成' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 生成面板 -->
|
|
||||||
<aside class="cv-panel">
|
|
||||||
<div class="cv-panel__title">生成到画布</div>
|
|
||||||
<label class="cv-label">模型</label>
|
|
||||||
<select v-if="imageModels.length" v-model="gen.modelId" class="cv-input" @change="gen.ratio = ratios[0]">
|
|
||||||
<option v-for="m in imageModels" :key="m.id" :value="m.id">
|
|
||||||
{{ m.alias || m.name || m.id }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<div v-else class="cv-nomodels">
|
|
||||||
⚠ 站点还没有可用的图像模型。请管理员到「管理后台 → 模型管理」添加并启用模型后,这里就能选择了。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="cv-label">比例</label>
|
|
||||||
<div class="cv-ratios">
|
|
||||||
<button
|
|
||||||
v-for="r in ratios"
|
|
||||||
:key="r"
|
|
||||||
type="button"
|
|
||||||
class="cv-chip"
|
|
||||||
:class="{ 'is-on': gen.ratio === r }"
|
|
||||||
@click="gen.ratio = r"
|
|
||||||
>{{ r }}</button>
|
|
||||||
</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">
|
|
||||||
{{ genBusy ? '生成中…' : '生成' }}
|
|
||||||
</button>
|
|
||||||
<div v-if="genMsg" class="cv-genmsg">{{ genMsg }}</div>
|
|
||||||
|
|
||||||
<div class="cv-tips">
|
|
||||||
双击空白建笔记 · 依次点击两个连接点连线,再点起点/空白/Esc 取消 ·
|
|
||||||
选中后右下角缩放 · Ctrl+D 复制 · 本地图片直接拖入或 Ctrl+V 粘贴 · 按 1 适应全部内容
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user