- 全站紫/青/粉写死色值与 CSS 变量改为墨黑中性色板 - 移除 aurora 光斑/星点装饰,登录弹窗改纯白卡片 - theme.js 固定亮色并清除历史 dark 选择,删除前台/后台/画布切换按钮 - 模型页空状态文案对齐「保存渠道即自动同步」 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
490 lines
14 KiB
Vue
490 lines
14 KiB
Vue
<script setup>
|
||
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
|
||
// 这里只负责节点自身内容:image / note / gen(生成中占位)。
|
||
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { generatedUrl, loadImageBlob, thumbUrl } from '../../api'
|
||
|
||
const props = defineProps({
|
||
node: { type: Object, required: true },
|
||
selected: { type: Boolean, default: false },
|
||
linkSourceSide: { type: String, default: '' },
|
||
})
|
||
const emit = defineEmits(['imgload', 'note-commit', 'node-action', 'port-click'])
|
||
const portSides = ['top', 'right', 'bottom', 'left']
|
||
const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
|
||
|
||
const imgLoaded = ref(false)
|
||
const imgBroken = ref(false)
|
||
const imgStatus = ref('')
|
||
const imgBlob = ref('')
|
||
const imgOriginal = computed(() => generatedUrl(props.node.src))
|
||
const noteEl = ref(null)
|
||
const editing = ref(false)
|
||
|
||
let imgController = null
|
||
async function loadSrc() {
|
||
if (!['image', 'video'].includes(props.node.type) || !props.node.src) return
|
||
imgController?.abort()
|
||
imgController = new AbortController()
|
||
imgLoaded.value = false
|
||
imgBroken.value = false
|
||
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
|
||
try {
|
||
const { url } = await loadImageBlob(props.node.src, imgController.signal)
|
||
imgBlob.value = url
|
||
} catch (e) {
|
||
imgBroken.value = true
|
||
const msg = e?.message || ''
|
||
const m = msg.match(/\((\d+)\)/)
|
||
imgStatus.value = m ? m[1] : '失败'
|
||
imgLoaded.value = true
|
||
}
|
||
}
|
||
|
||
onMounted(() => loadSrc())
|
||
onUnmounted(() => {
|
||
imgController?.abort()
|
||
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
|
||
})
|
||
watch(() => props.node.src, loadSrc)
|
||
|
||
onMounted(() => {
|
||
// 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。
|
||
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || ''
|
||
})
|
||
|
||
// 撤销/重做会整体替换 nodes 数组但复用组件(key 不变),DOM 文字会变成
|
||
// 旧值 —— 非编辑状态下把 node.text 同步回 DOM。
|
||
watch(
|
||
() => props.node.text,
|
||
(t) => {
|
||
if (props.node.type !== 'note' || editing.value || !noteEl.value) return
|
||
const want = t || ''
|
||
if (noteEl.value.innerText !== want) noteEl.value.innerText = want
|
||
},
|
||
)
|
||
|
||
function onImgLoad(e) {
|
||
imgLoaded.value = true
|
||
imgBroken.value = false
|
||
const img = e.target
|
||
// 按真实长宽比校正节点尺寸(以长边 512 归一,保持画布视觉密度一致)。
|
||
if (img.naturalWidth && img.naturalHeight) {
|
||
const long = Math.max(img.naturalWidth, img.naturalHeight)
|
||
const k = 512 / long
|
||
emit('imgload', {
|
||
w: Math.round(img.naturalWidth * k),
|
||
h: Math.round(img.naturalHeight * k),
|
||
})
|
||
}
|
||
}
|
||
function onVidMeta(e) {
|
||
imgLoaded.value = true
|
||
imgBroken.value = false
|
||
const v = e.target
|
||
if (v.videoWidth && v.videoHeight) {
|
||
const long = Math.max(v.videoWidth, v.videoHeight)
|
||
const k = 512 / long
|
||
emit('imgload', {
|
||
w: Math.round(v.videoWidth * k),
|
||
h: Math.round(v.videoHeight * k),
|
||
})
|
||
}
|
||
}
|
||
|
||
function startEdit() {
|
||
if (props.node.type !== 'note') return
|
||
editing.value = true
|
||
requestAnimationFrame(() => {
|
||
const el = noteEl.value
|
||
if (el) {
|
||
el.innerText = props.node.text || ''
|
||
el.focus()
|
||
// 光标落末尾
|
||
const sel = window.getSelection()
|
||
const range = document.createRange()
|
||
range.selectNodeContents(el)
|
||
range.collapse(false)
|
||
sel.removeAllRanges()
|
||
sel.addRange(range)
|
||
}
|
||
})
|
||
}
|
||
|
||
function commitNote() {
|
||
const el = noteEl.value
|
||
editing.value = false
|
||
if (!el) return
|
||
// 不依赖 editing 标志做早退:焦点/事件时序可能把标志先置 false,
|
||
// 但用户输入的文字仍然必须被提交(否则静默丢字)。
|
||
const text = (el.innerText || '').replace(/\u00a0/g, ' ').slice(0, 4000)
|
||
if (text !== (props.node.text || '')) emit('note-commit', text)
|
||
}
|
||
|
||
// 双击:笔记 → 进入编辑(自己处理);图片 → 交给父级开预览。
|
||
function onSelfDblClick(e) {
|
||
if (props.node.type === 'note') {
|
||
e.stopPropagation()
|
||
startEdit()
|
||
} else if (props.node.type === 'image') {
|
||
e.stopPropagation()
|
||
emit('node-action', { type: 'preview' })
|
||
}
|
||
}
|
||
|
||
defineExpose({ startEdit, commitNote })
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="cv-node"
|
||
:class="[
|
||
`cv-node--${node.type}`,
|
||
{ 'is-selected': selected, 'is-editing': editing, 'is-failed': node.type === 'gen' && node.status === 'failed' },
|
||
]"
|
||
:style="{ left: node.x + 'px', top: node.y + 'px', width: node.w + 'px', height: node.h + 'px', zIndex: node.z || 0 }"
|
||
:data-node-id="node.id"
|
||
@dblclick="onSelfDblClick"
|
||
>
|
||
<!-- 连线出口:点选起点和终点建立连线 -->
|
||
<button v-for="side in portSides" :key="side"
|
||
type="button" class="cv-port" :class="[`cv-port--${side}`, { 'is-active': linkSourceSide === side }]"
|
||
:aria-label="`${portLabels[side]}侧连接点`" :aria-pressed="linkSourceSide === side"
|
||
:title="`${portLabels[side]}侧连接点`"
|
||
@pointerdown.stop @dblclick.stop @click.stop="emit('port-click', side)" />
|
||
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
|
||
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
|
||
<div v-if="node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
|
||
<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">
|
||
<!-- 图片节点 -->
|
||
<template v-if="node.type === 'image'">
|
||
<img
|
||
v-if="!imgBroken && imgBlob"
|
||
class="cv-node__img"
|
||
:src="imgBlob"
|
||
decoding="async"
|
||
draggable="false"
|
||
alt=""
|
||
@load="onImgLoad"
|
||
>
|
||
<div v-else class="cv-node__missing">图片不可用<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
|
||
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||
</template>
|
||
|
||
<!-- 视频节点 -->
|
||
<template v-else-if="node.type === 'video'">
|
||
<video
|
||
v-if="!imgBroken && imgBlob"
|
||
class="cv-node__img"
|
||
:src="imgBlob"
|
||
controls
|
||
muted
|
||
loop
|
||
playsinline
|
||
@loadedmetadata="onVidMeta"
|
||
@error="imgBroken = true"
|
||
/>
|
||
<div v-else class="cv-node__missing">{{ imgBroken ? '视频不可用' : '加载中…' }}<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
|
||
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||
</template>
|
||
|
||
<!-- 笔记节点 -->
|
||
<div
|
||
v-else-if="node.type === 'note'"
|
||
ref="noteEl"
|
||
class="cv-node__note"
|
||
:contenteditable="editing"
|
||
spellcheck="false"
|
||
@blur="commitNote"
|
||
@keydown.enter.exact.prevent="commitNote"
|
||
/>
|
||
|
||
<!-- 生成占位节点 -->
|
||
<div v-else class="cv-node__gen">
|
||
<template v-if="node.status === 'running'">
|
||
<span class="cv-node__spin" />
|
||
<span class="cv-node__genlabel">生成中…</span>
|
||
<span class="cv-node__gensub">{{ node.model || 'AI' }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="cv-node__x">✕</span>
|
||
<span>生成失败</span>
|
||
<span class="cv-node__err" :title="node.error || ''">{{ node.error || '请检查提示词后重试' }}</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.cv-node {
|
||
position: absolute;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--glass-border);
|
||
contain: layout style;
|
||
user-select: none;
|
||
touch-action: none;
|
||
cursor: grab;
|
||
background: var(--surface-solid);
|
||
box-shadow: var(--glass-shadow);
|
||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.cv-node__clip {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 13px;
|
||
overflow: hidden;
|
||
}
|
||
.cv-node.is-selected {
|
||
border-color: rgb(24 24 27 / 0.7);
|
||
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(24 24 27 / 0.25), 0 10px 30px var(--accent-glow);
|
||
}
|
||
.cv-node:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.cv-node__img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
background: var(--surface-2);
|
||
}
|
||
.cv-node__missing small { display: block; margin-top: 4px; font-weight: 500; opacity: 0.7; }
|
||
.cv-node__missing {
|
||
width: 100%;
|
||
height: 100%;
|
||
align-content: center;
|
||
text-align: center;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--fg-3);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
background: var(--surface-2);
|
||
}
|
||
|
||
.cv-node__cap {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 36px 14px 12px;
|
||
font-size: 32px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
color: #fff;
|
||
text-shadow: 0 1px 4px rgb(0 0 0 / 0.65);
|
||
background: linear-gradient(to top, rgb(0 0 0 / 0.66), transparent);
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 笔记 = 淡黄色贴纸 */
|
||
.cv-node--note {
|
||
background: #fdf6c9;
|
||
border-color: rgb(180 140 20 / 0.35);
|
||
cursor: text;
|
||
}
|
||
html.dark .cv-node--note {
|
||
background: rgb(250 204 21 / 0.14);
|
||
border-color: rgb(250 204 21 / 0.35);
|
||
}
|
||
.cv-node--note:not(.is-editing) {
|
||
cursor: grab;
|
||
}
|
||
.cv-node__note {
|
||
width: 100%;
|
||
height: 100%;
|
||
padding: 12px 14px;
|
||
font-size: 32px;
|
||
line-height: 1.35;
|
||
color: #111;
|
||
outline: none;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow: hidden;
|
||
user-select: none;
|
||
}
|
||
.is-editing .cv-node__note {
|
||
user-select: text;
|
||
cursor: text;
|
||
}
|
||
|
||
.cv-node--gen {
|
||
border-style: dashed;
|
||
color: var(--fg-2);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.cv-node--gen.is-failed {
|
||
border-color: #e5484d;
|
||
color: #e5484d;
|
||
}
|
||
/* 生成占位内容:居中排布 + 微光扫过 */
|
||
.cv-node__gen {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
background:
|
||
radial-gradient(130% 130% at 15% 0%, rgb(24 24 27 / 0.14), transparent 55%),
|
||
radial-gradient(130% 130% at 85% 100%, rgb(24 24 27 / 0.12), transparent 55%),
|
||
var(--surface);
|
||
}
|
||
.cv-node__gen::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(105deg, transparent 38%, rgb(255 255 255 / 0.18) 50%, transparent 62%);
|
||
background-size: 260% 100%;
|
||
background-position: 260% 0;
|
||
animation: cv-gen-shine 2.4s linear infinite;
|
||
pointer-events: none;
|
||
}
|
||
.is-failed .cv-node__gen {
|
||
background:
|
||
radial-gradient(130% 130% at 15% 0%, rgb(229 72 77 / 0.1), transparent 55%),
|
||
var(--surface);
|
||
}
|
||
.is-failed .cv-node__gen::after {
|
||
display: none;
|
||
}
|
||
.cv-node__genlabel {
|
||
letter-spacing: 2px;
|
||
}
|
||
.cv-node__gensub {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--fg-faint);
|
||
}
|
||
.cv-node__spin {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
border: 3px solid rgb(24 24 27 / 0.2);
|
||
border-top-color: var(--m-orange);
|
||
border-right-color: var(--m-orange-2);
|
||
animation: cv-spin 0.9s linear infinite;
|
||
filter: drop-shadow(0 0 10px var(--accent-glow));
|
||
}
|
||
@keyframes cv-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
@keyframes cv-gen-shine {
|
||
to { background-position: -260% 0; }
|
||
}
|
||
.cv-node__err {
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
max-width: min(90%, 320px);
|
||
max-height: 5em;
|
||
overflow: auto;
|
||
overflow-wrap: anywhere;
|
||
text-align: center;
|
||
user-select: text;
|
||
}
|
||
|
||
.cv-port {
|
||
position: absolute;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
z-index: 3;
|
||
touch-action: manipulation;
|
||
}
|
||
.cv-port::after {
|
||
content: '';
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
border: 3px solid var(--m-orange);
|
||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.35), 0 0 0 2px rgb(255 255 255 / 0.8);
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.cv-port:hover::after, .cv-port:focus-visible::after, .cv-port.is-active::after {
|
||
transform: scale(1.3);
|
||
box-shadow: 0 0 0 4px rgb(24 24 27 / 0.25), 0 2px 10px var(--accent-glow);
|
||
}
|
||
.cv-port.is-active::after { background: var(--m-orange); }
|
||
.cv-port:focus-visible { outline: 2px solid var(--m-orange); outline-offset: -2px; border-radius: 50%; }
|
||
.cv-port--top { top: -17px; left: 50%; transform: translateX(-50%); }
|
||
.cv-port--right { right: -17px; top: 50%; transform: translateY(-50%); }
|
||
.cv-port--bottom { bottom: -17px; left: 50%; transform: translateX(-50%); }
|
||
.cv-port--left { left: -17px; top: 50%; transform: translateY(-50%); }
|
||
|
||
/* 右下角缩放方块(节点 contain:paint,只能放内侧) */
|
||
.cv-size {
|
||
position: absolute;
|
||
right: 4px;
|
||
bottom: 4px;
|
||
width: 15px;
|
||
height: 15px;
|
||
border-radius: 4px;
|
||
background: var(--surface-solid);
|
||
border: 1px solid var(--ink);
|
||
cursor: nwse-resize;
|
||
z-index: 3;
|
||
}
|
||
|
||
/* 悬浮工具条:鼠标放上节点就显示(选中时常显),贴节点内上缘 */
|
||
.cv-tools {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
display: flex;
|
||
gap: 4px;
|
||
z-index: 4;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.12s ease;
|
||
}
|
||
.cv-node:hover .cv-tools,
|
||
.cv-node.is-selected .cv-tools,
|
||
.cv-tools:focus-within {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
.cv-tools button {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 7px;
|
||
border: 1px solid var(--glass-border);
|
||
background: var(--menu-bg);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
backdrop-filter: blur(10px);
|
||
color: var(--fg);
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
box-shadow: var(--glass-shadow);
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 0;
|
||
transition: transform 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||
}
|
||
.cv-tools button:hover {
|
||
transform: translateY(-1px);
|
||
background: var(--hover);
|
||
}
|
||
.cv-tools__del {
|
||
color: #e5484d;
|
||
}
|
||
.cv-tools__del:hover {
|
||
background: rgb(229 72 77 / 0.14) !important;
|
||
}
|
||
</style>
|