1450 lines
41 KiB
Vue
1450 lines
41 KiB
Vue
<script setup>
|
||
// 无限画布页面:文档管理(多画布/自动保存)、生成面板、撤销重做、快捷键。
|
||
// 交互细节全部在 CanvasStage;这里只做数据编排。
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
import { onBeforeRouteLeave } from 'vue-router'
|
||
import { api, generatedUrl, jsonBody, thumbUrl } from '../api'
|
||
import { auth } from '../auth'
|
||
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, DEFAULT_SCALE } from '../utils/canvas'
|
||
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
||
import SelectMenu from '../components/SelectMenu.vue'
|
||
|
||
const stage = ref(null)
|
||
|
||
// ---- 文档状态 ----
|
||
const canvases = ref([])
|
||
const currentId = ref('')
|
||
const title = ref('未命名画布')
|
||
const doc = reactive({ viewport: { x: 0, y: 0, scale: DEFAULT_SCALE }, nodes: [], edges: [] })
|
||
const loading = ref(true)
|
||
const loadError = ref('')
|
||
const saveState = ref('saved') // saved | dirty | saving | error
|
||
const scalePct = ref(100)
|
||
|
||
// ---- 选择 / 预览 ----
|
||
const selectionIds = ref([])
|
||
const previewSrc = ref('')
|
||
|
||
// ---- 生成面板 ----
|
||
const allModels = ref([])
|
||
const gen = reactive({ kind: 'image', modelId: '', prompt: '', ratio: '', resolution: '', duration: '', count: 1 })
|
||
const runningGens = ref(0)
|
||
const genBusy = computed(() => runningGens.value > 0)
|
||
const genMsg = ref('')
|
||
const kindModels = computed(() =>
|
||
(allModels.value || []).filter((m) => m.enabled !== false && m.type === gen.kind),
|
||
)
|
||
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
||
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
||
const resolutions = computed(() => model.value?.resolutions?.length
|
||
? model.value.resolutions
|
||
: Object.keys(model.value?.prices || {}))
|
||
const durations = computed(() => model.value?.durations?.length ? model.value.durations : ['5s'])
|
||
// SelectMenu options(统一全站自定义下拉样式)
|
||
const kindOptions = [
|
||
{ value: 'image', label: '生图' },
|
||
{ value: 'video', label: '生视频' },
|
||
]
|
||
const modelOptions = computed(() =>
|
||
kindModels.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
||
)
|
||
const ratioOptions = computed(() => ratios.value.map((r) => ({ value: r, label: r })))
|
||
const resolutionOptions = computed(() => resolutions.value.map((r) => ({ value: r, label: r })))
|
||
const durationOptions = computed(() => durations.value.map((d) => ({ value: d, label: d })))
|
||
const countOptions = [1, 2, 3, 4].map((n) => ({ value: n, label: `${n} 张` }))
|
||
const canvasOptions = computed(() =>
|
||
canvases.value.map((c) => ({ value: c.id, label: c.title || '未命名画布' })),
|
||
)
|
||
const maxRefs = computed(() => Math.min(8, Math.max(
|
||
Number(model.value?.max_reference_images || 0), model.value?.image_to_image ? 1 : 0,
|
||
)))
|
||
// 换类型/模型时重置参数默认值(和画图台一致)
|
||
watch(() => gen.kind, () => {
|
||
const first = kindModels.value[0]
|
||
gen.modelId = first?.id || ''
|
||
})
|
||
watch(model, (m) => {
|
||
if (!m) return
|
||
gen.ratio = m.ratios?.[0] || '1:1'
|
||
const res = m.resolutions?.length ? m.resolutions : Object.keys(m.prices || {})
|
||
gen.resolution = res[0] || '1K'
|
||
gen.duration = m.durations?.[0] || '5s'
|
||
})
|
||
const selectedNotes = computed(() =>
|
||
selectionIds.value
|
||
.map((id) => doc.nodes.find((n) => n.id === id))
|
||
.filter((n) => n && n.type === 'note' && (n.text || '').trim()),
|
||
)
|
||
|
||
// ---- 我的图片插入 ----
|
||
const pickerOpen = ref(false)
|
||
const pickerItems = ref([])
|
||
const pickerLoading = ref(false)
|
||
const fileInput = ref(null)
|
||
const uploadBusy = ref(false)
|
||
const dropHover = ref(false)
|
||
|
||
// ================= 文档加载 / 保存 =================
|
||
|
||
async function loadList() {
|
||
const r = await api('/canvas')
|
||
if (!r.ok) throw new Error(r.data?.detail || '加载画布列表失败')
|
||
canvases.value = r.data?.data || []
|
||
}
|
||
|
||
// ---- 本地缓存(localStorage):打开画布先渲染缓存秒开,再后台对齐服务器 ----
|
||
const CV_CACHE_PREFIX = 'cv:cache:'
|
||
|
||
function readCache(id) {
|
||
try {
|
||
const raw = localStorage.getItem(CV_CACHE_PREFIX + id)
|
||
if (!raw) return null
|
||
const c = JSON.parse(raw)
|
||
if (typeof c.data !== 'string') return null
|
||
return { title: typeof c.title === 'string' ? c.title : '未命名画布', data: c.data }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
function writeCache(id) {
|
||
if (!id) return
|
||
try {
|
||
localStorage.setItem(
|
||
CV_CACHE_PREFIX + id,
|
||
JSON.stringify({ title: title.value, data: serializeDoc(doc) }),
|
||
)
|
||
} catch { /* 配额满等异常:缓存是尽力而为,失败不影响功能 */ }
|
||
}
|
||
function clearCache(id) {
|
||
try { localStorage.removeItem(CV_CACHE_PREFIX + id) } catch { /* 同上 */ }
|
||
}
|
||
|
||
function applyDoc(id, docTitle, d) {
|
||
currentId.value = id
|
||
title.value = docTitle || '未命名画布'
|
||
doc.viewport = d.viewport
|
||
doc.nodes = d.nodes
|
||
doc.edges = d.edges
|
||
selectionIds.value = []
|
||
resetHistory()
|
||
saveState.value = 'saved'
|
||
}
|
||
|
||
async function loadCanvas(id) {
|
||
// 1) 本地缓存先行:立即渲染、立即可交互(离线也可用)
|
||
let appliedFromCache = false
|
||
const cached = readCache(id)
|
||
if (cached) {
|
||
const cd = normalizeDoc(safeParse(cached.data))
|
||
for (const n of cd.nodes) {
|
||
if (n.type === 'gen' && n.status === 'running') {
|
||
n.status = 'failed'
|
||
n.error = '页面已刷新,任务中断'
|
||
}
|
||
}
|
||
applyDoc(id, cached.title, cd)
|
||
appliedFromCache = true
|
||
loading.value = false
|
||
await nextTick()
|
||
stage.value?.setViewport(cd.viewport)
|
||
stage.value?.syncZTop()
|
||
}
|
||
|
||
// 2) 服务器版本对齐
|
||
const r = await api(`/canvas/${id}`)
|
||
if (!r.ok) {
|
||
if (appliedFromCache) return // 拉取失败但缓存可用:继续用缓存
|
||
throw new Error(r.data?.detail || '加载画布失败')
|
||
}
|
||
const serverData = typeof r.data?.data === 'string' ? r.data.data : serializeDoc(normalizeDoc(safeParse(r.data?.data)))
|
||
if (appliedFromCache && cached && cached.data === serverData && cached.title === (r.data.title || '未命名画布')) {
|
||
writeCache(id) // 刷新缓存时间戳意义不大,但保持一致路径
|
||
return // 内容一致:不重刷,保留撤销历史与当前交互
|
||
}
|
||
const d = normalizeDoc(safeParse(r.data?.data))
|
||
// 页面刷新会丢掉进行中的生成任务结果(无轮询),如实标记中断。
|
||
for (const n of d.nodes) {
|
||
if (n.type === 'gen' && n.status === 'running') {
|
||
n.status = 'failed'
|
||
n.error = '页面已刷新,任务中断'
|
||
}
|
||
}
|
||
applyDoc(r.data.id, r.data.title, d)
|
||
await nextTick()
|
||
stage.value?.setViewport(d.viewport)
|
||
stage.value?.syncZTop()
|
||
writeCache(id)
|
||
}
|
||
|
||
function safeParse(s) {
|
||
try {
|
||
return typeof s === 'string' ? JSON.parse(s) : s
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function boot() {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
await loadList()
|
||
if (!canvases.value.length) {
|
||
const r = await api('/canvas', jsonBody('POST', { title: '我的画布' }))
|
||
if (!r.ok) throw new Error(r.data?.detail || '创建画布失败')
|
||
await loadList()
|
||
}
|
||
await loadCanvas(canvases.value[0].id)
|
||
} catch (e) {
|
||
loadError.value = String(e.message || e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 自动保存 ----
|
||
let saveTimer = null
|
||
let retryTimer = null
|
||
|
||
function markDirty() {
|
||
if (!currentId.value) return
|
||
saveState.value = 'dirty'
|
||
clearTimeout(saveTimer)
|
||
saveTimer = setTimeout(saveNow, 900)
|
||
}
|
||
|
||
async function saveNow() {
|
||
if (!currentId.value || saveState.value === 'saving') return
|
||
clearTimeout(saveTimer)
|
||
saveState.value = 'saving'
|
||
try {
|
||
const r = await api(
|
||
`/canvas/${currentId.value}`,
|
||
jsonBody('PUT', { title: title.value, data: serializeDoc(doc) }),
|
||
)
|
||
if (r.ok) {
|
||
saveState.value = 'saved'
|
||
writeCache(currentId.value)
|
||
const row = canvases.value.find((c) => c.id === currentId.value)
|
||
if (row) row.title = title.value
|
||
} else {
|
||
saveState.value = 'error'
|
||
clearTimeout(retryTimer)
|
||
retryTimer = setTimeout(saveNow, 4000)
|
||
}
|
||
} catch {
|
||
saveState.value = 'error'
|
||
clearTimeout(retryTimer)
|
||
retryTimer = setTimeout(saveNow, 4000)
|
||
}
|
||
}
|
||
|
||
function flushSync() {
|
||
// 页面卸载时的最后努力(fetch keepalive 不保证,但值得试)
|
||
if (saveState.value === 'dirty' || saveState.value === 'error') {
|
||
const body = JSON.stringify({ title: title.value, data: serializeDoc(doc) })
|
||
try {
|
||
fetch(`/admin/api/canvas/${currentId.value}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
||
},
|
||
body,
|
||
keepalive: true,
|
||
})
|
||
} catch { /* 尽力而为 */ }
|
||
}
|
||
}
|
||
|
||
// ================= 撤销 / 重做 =================
|
||
|
||
const undoStack = []
|
||
const redoStack = []
|
||
const undoCount = ref(0)
|
||
const redoCount = ref(0)
|
||
const HISTORY_CAP = 60
|
||
let currentSnap = ''
|
||
|
||
function syncCounts() {
|
||
undoCount.value = undoStack.length
|
||
redoCount.value = redoStack.length
|
||
}
|
||
function snapshotContent() {
|
||
return JSON.stringify({ nodes: doc.nodes, edges: doc.edges })
|
||
}
|
||
function resetHistory() {
|
||
undoStack.length = 0
|
||
redoStack.length = 0
|
||
currentSnap = snapshotContent()
|
||
syncCounts()
|
||
}
|
||
function pushHistory() {
|
||
const s = snapshotContent()
|
||
if (s === currentSnap) return
|
||
undoStack.push(currentSnap)
|
||
if (undoStack.length > HISTORY_CAP) undoStack.shift()
|
||
redoStack.length = 0
|
||
currentSnap = s
|
||
syncCounts()
|
||
markDirty()
|
||
}
|
||
function restore(json) {
|
||
const d = JSON.parse(json)
|
||
doc.nodes = d.nodes || []
|
||
doc.edges = d.edges || []
|
||
const alive = new Set(doc.nodes.map((n) => n.id))
|
||
selectionIds.value = selectionIds.value.filter((id) => alive.has(id))
|
||
stage.value?.syncZTop()
|
||
}
|
||
function undo() {
|
||
if (!undoStack.length) return
|
||
redoStack.push(currentSnap)
|
||
const prev = undoStack.pop()
|
||
restore(prev)
|
||
currentSnap = snapshotContent()
|
||
syncCounts()
|
||
markDirty()
|
||
}
|
||
function redo() {
|
||
if (!redoStack.length) return
|
||
undoStack.push(currentSnap)
|
||
const next = redoStack.pop()
|
||
restore(next)
|
||
currentSnap = snapshotContent()
|
||
syncCounts()
|
||
markDirty()
|
||
}
|
||
|
||
// ================= 节点操作 =================
|
||
|
||
function addNote() {
|
||
addNoteAt(viewportCenterWorld())
|
||
}
|
||
|
||
function addNoteAt(w) {
|
||
const n = {
|
||
id: newId('t'), type: 'note', text: '',
|
||
x: Math.round(w.x - 130), y: Math.round(w.y - 70), w: 260, h: 140, z: 0,
|
||
}
|
||
doc.nodes.push(n)
|
||
selectionIds.value = [n.id]
|
||
pushHistory()
|
||
}
|
||
|
||
function addImageNode(src, prompt = '', at = null) {
|
||
const c = at || viewportCenterWorld()
|
||
const n = {
|
||
id: newId('i'), type: 'image', src, prompt,
|
||
x: Math.round(c.x - 256 + cascade()), y: Math.round(c.y - 256 + cascade()),
|
||
w: 512, h: 512, z: 0,
|
||
}
|
||
doc.nodes.push(n)
|
||
selectionIds.value = [n.id]
|
||
pushHistory()
|
||
}
|
||
|
||
let cascadeN = 0
|
||
function cascade() {
|
||
cascadeN = (cascadeN + 1) % 8
|
||
return cascadeN * 24
|
||
}
|
||
|
||
function viewportCenterWorld() {
|
||
const vp = stage.value?.vp
|
||
const el = document.querySelector('.cv-stage')
|
||
const w = el ? el.clientWidth : 800
|
||
const h = el ? el.clientHeight : 600
|
||
if (!vp) return { x: 0, y: 0 }
|
||
return screenToWorld(w / 2, h / 2, vp)
|
||
}
|
||
|
||
// ---- 复制选中节点(带相互连线,偏移落位) ----
|
||
function duplicateSelection() {
|
||
const sel = doc.nodes.filter((n) => selectionIds.value.includes(n.id) && n.type !== 'gen')
|
||
if (!sel.length) return
|
||
const idMap = {}
|
||
const clones = sel.map((n) => {
|
||
const c = {
|
||
...n,
|
||
id: newId(n.type === 'note' ? 't' : 'i'),
|
||
x: n.x + 36,
|
||
y: n.y + 36,
|
||
}
|
||
idMap[n.id] = c.id
|
||
return c
|
||
})
|
||
doc.nodes.push(...clones)
|
||
for (const e of [...doc.edges]) {
|
||
if (idMap[e.from] && idMap[e.to]) {
|
||
doc.edges.push({ ...e, id: newId('e'), from: idMap[e.from], to: idMap[e.to] })
|
||
}
|
||
}
|
||
selectionIds.value = clones.map((c) => c.id)
|
||
stage.value?.selectIds?.(selectionIds.value)
|
||
pushHistory()
|
||
}
|
||
|
||
function deleteSelection() {
|
||
const dead = new Set(selectionIds.value)
|
||
const deadEdges = stage.value?.consumeSelectedEdges() || []
|
||
if (!dead.size && !deadEdges.length) return
|
||
if (dead.size) {
|
||
doc.nodes = doc.nodes.filter((n) => !dead.has(n.id))
|
||
doc.edges = doc.edges.filter((e) => !dead.has(e.from) && !dead.has(e.to))
|
||
}
|
||
if (deadEdges.length) {
|
||
const gone = new Set(deadEdges)
|
||
doc.edges = doc.edges.filter((e) => !gone.has(e.id))
|
||
}
|
||
selectionIds.value = []
|
||
pushHistory()
|
||
}
|
||
|
||
// ---- 手动连线 ----
|
||
function onLinkCreate({ from, to, fromSide, toSide }) {
|
||
// 任意方向去重:两个节点之间只允许一条连线
|
||
if (doc.edges.some((e) => (e.from === from && e.to === to) || (e.from === to && e.to === from))) return
|
||
doc.edges.push({ id: newId('e'), from, to, fromSide, toSide })
|
||
pushHistory()
|
||
}
|
||
|
||
// ---- 画布管理 ----
|
||
|
||
async function switchCanvas(id) {
|
||
if (id === currentId.value) return
|
||
await saveNow()
|
||
loading.value = true
|
||
try {
|
||
await loadCanvas(id)
|
||
} catch (e) {
|
||
loadError.value = String(e.message || e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function newCanvas() {
|
||
await saveNow()
|
||
const r = await api('/canvas', jsonBody('POST', { title: `画布 ${canvases.value.length + 1}` }))
|
||
if (!r.ok) return
|
||
await loadList()
|
||
await switchCanvas(r.data.id)
|
||
}
|
||
|
||
async function renameCanvas() {
|
||
const t = window.prompt('画布标题', title.value)
|
||
if (t === null) return
|
||
title.value = (t.trim() || '未命名画布').slice(0, 120)
|
||
await saveNow()
|
||
}
|
||
|
||
async function deleteCanvas() {
|
||
if (!window.confirm(`删除画布「${title.value}」?此操作不可撤销。`)) return
|
||
const r = await api(`/canvas/${currentId.value}`, { method: 'DELETE' })
|
||
if (!r.ok) return
|
||
clearCache(currentId.value)
|
||
await loadList()
|
||
if (canvases.value.length) {
|
||
await loadCanvas(canvases.value[0].id)
|
||
} else {
|
||
const c = await api('/canvas', jsonBody('POST', { title: '我的画布' }))
|
||
await loadList()
|
||
if (c.ok) await loadCanvas(c.data.id)
|
||
}
|
||
}
|
||
|
||
// ---- 我的图片 ----
|
||
|
||
async function openPicker() {
|
||
pickerOpen.value = true
|
||
if (pickerItems.value.length) return
|
||
pickerLoading.value = true
|
||
const r = await api('/my-images')
|
||
pickerItems.value = (r.data?.data || []).filter((f) =>
|
||
/\.(png|jpe?g|webp|gif)$/i.test(f.name || ''),
|
||
)
|
||
pickerLoading.value = false
|
||
}
|
||
|
||
// ---- 上传本地图片(按钮/拖拽/粘贴三条路径都汇到这里) ----
|
||
async function uploadImageFile(file, at = null) {
|
||
if (!file || !file.type?.startsWith('image/')) return
|
||
uploadBusy.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
const r = await api('/my-images', { method: 'POST', body: fd })
|
||
if (r.ok && r.data?.name) {
|
||
pickerItems.value.unshift({ name: r.data.name })
|
||
addImageNode(r.data.name, '', at)
|
||
} else {
|
||
genMsg.value = r.data?.detail || `上传失败 (${r.status})`
|
||
}
|
||
} catch (e) {
|
||
genMsg.value = String(e)
|
||
} finally {
|
||
uploadBusy.value = false
|
||
}
|
||
}
|
||
|
||
function onFilePicked(e) {
|
||
const files = [...(e.target.files || [])]
|
||
e.target.value = ''
|
||
files.forEach((f) => uploadImageFile(f))
|
||
}
|
||
|
||
// 拖拽本地图片落到画布:落点即拖放位置
|
||
function onDrop(e) {
|
||
dropHover.value = false
|
||
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith('image/'))
|
||
if (!files.length) return
|
||
const el = document.querySelector('.cv-stage')
|
||
const rect = el?.getBoundingClientRect()
|
||
const vp = stage.value?.vp
|
||
const at =
|
||
rect && vp ? screenToWorld(e.clientX - rect.left, e.clientY - rect.top, vp) : null
|
||
files.forEach((f) => uploadImageFile(f, at))
|
||
}
|
||
|
||
function onPaste(e) {
|
||
if (isTyping(e.target)) return
|
||
for (const it of e.clipboardData?.items || []) {
|
||
if (it.type?.startsWith('image/')) {
|
||
const f = it.getAsFile()
|
||
if (f) {
|
||
e.preventDefault()
|
||
uploadImageFile(f)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ================= 生成 =================
|
||
|
||
// ---- 参考图上传(与画图台一致:直接传文件,不再用选中节点) ----
|
||
const refImages = ref([]) // { name, dataUrl }
|
||
const refInput = ref(null)
|
||
const MAX_REF_BYTES = 15 * 1024 * 1024
|
||
|
||
function fileToDataUrl(f) {
|
||
return new Promise((resolve, reject) => {
|
||
const fr = new FileReader()
|
||
fr.onload = () => resolve(fr.result)
|
||
fr.onerror = reject
|
||
fr.readAsDataURL(f)
|
||
})
|
||
}
|
||
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() {
|
||
if (!model.value) return
|
||
const prompt = (gen.prompt || '').trim() || selectedNotes.value.map((n) => n.text.trim()).join('\n')
|
||
if (!prompt) {
|
||
genMsg.value = '请输入提示词,或选中一个有内容的笔记节点'
|
||
return
|
||
}
|
||
if (refImages.value.length && !maxRefs.value) {
|
||
genMsg.value = '当前模型不支持参考图,请切换模型'
|
||
return
|
||
}
|
||
genMsg.value = ''
|
||
|
||
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]
|
||
// 落点:参与源节点的包围盒右侧;无源则视口中心。多张时横向排开。
|
||
let x
|
||
let y
|
||
if (sources.length) {
|
||
let maxX = -Infinity
|
||
let minY = Infinity
|
||
for (const s of sources) {
|
||
maxX = Math.max(maxX, s.x + s.w)
|
||
minY = Math.min(minY, s.y)
|
||
}
|
||
x = maxX + 64
|
||
y = minY
|
||
} else {
|
||
const c = viewportCenterWorld()
|
||
const totalW = n * size.w + (n - 1) * 48
|
||
x = c.x - totalW / 2
|
||
y = c.y - size.h / 2
|
||
}
|
||
|
||
const created = []
|
||
for (let i = 0; i < n; i++) {
|
||
const node = reactive({
|
||
id: newId('g'), type: 'gen', status: 'running',
|
||
x: Math.round(x + i * (size.w + 48)), y: Math.round(y), w: size.w, h: size.h, z: 0,
|
||
})
|
||
doc.nodes.push(node)
|
||
for (const s of sources) {
|
||
doc.edges.push({ id: newId('e'), from: s.id, to: node.id })
|
||
}
|
||
created.push(node)
|
||
runGeneration(node, prompt)
|
||
}
|
||
selectionIds.value = created.map((c) => c.id)
|
||
stage.value?.selectIds(selectionIds.value)
|
||
pushHistory()
|
||
}
|
||
|
||
// 单个生成任务(并发执行,各自更新自己的节点)
|
||
async function runGeneration(node, prompt) {
|
||
runningGens.value++
|
||
try {
|
||
const payload = {
|
||
model: gen.modelId, prompt, ratio: gen.ratio || ratios.value[0],
|
||
resolution: gen.resolution || resolutions.value[0] || '1K',
|
||
}
|
||
if (gen.kind === 'video') payload.duration = gen.duration || durations.value[0]
|
||
if (refImages.value.length) {
|
||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||
}
|
||
const opts = jsonBody('POST', payload)
|
||
opts.headers['Idempotency-Key'] = node.id
|
||
const r = await api('/generate', opts)
|
||
if (r.ok && r.data?.url) {
|
||
node.type = gen.kind === 'video' ? 'video' : 'image'
|
||
node.src = r.data.url.replace(/^\/images\//, '')
|
||
node.prompt = prompt
|
||
node.model = model.value?.alias || model.value?.name || model.value?.id
|
||
delete node.status
|
||
delete node.error
|
||
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
|
||
genMsg.value = `完成 · 扣 ${r.data.charged ?? '?'} 积分 · ${r.data.elapsed_ms ?? '?'}ms`
|
||
pushHistory()
|
||
} else if (r.status === 409 || [502, 504, 522, 524].includes(r.status)) {
|
||
// 网关重发/超时:后端可能仍在生成。节点保持"生成中",用户可稍后删除。
|
||
genMsg.value = '请求超时或重复提交,任务可能仍在后台执行'
|
||
} else {
|
||
node.status = 'failed'
|
||
node.error = r.data?.detail || `失败 (${r.status})`
|
||
genMsg.value = node.error
|
||
pushHistory()
|
||
}
|
||
} catch (e) {
|
||
node.status = 'failed'
|
||
node.error = String(e)
|
||
genMsg.value = String(e)
|
||
pushHistory()
|
||
} finally {
|
||
runningGens.value--
|
||
}
|
||
}
|
||
|
||
// ================= 舞台事件 =================
|
||
|
||
function onSelection(ids) {
|
||
selectionIds.value = ids
|
||
}
|
||
function onMoveEnd() {
|
||
pushHistory()
|
||
}
|
||
function onNoteCommit({ id, text }) {
|
||
const n = doc.nodes.find((x) => x.id === id)
|
||
if (!n) return
|
||
n.text = text
|
||
pushHistory()
|
||
}
|
||
function onImgSize({ id, w, h }) {
|
||
const n = doc.nodes.find((x) => x.id === id)
|
||
if (!n || w <= 0 || h <= 0) return
|
||
if (Math.abs(n.w - w) <= 1 && Math.abs(n.h - h) <= 1) return
|
||
// 保持中心不动的尺寸校正
|
||
const cx = n.x + n.w / 2
|
||
const cy = n.y + n.h / 2
|
||
n.w = w
|
||
n.h = h
|
||
n.x = Math.round(cx - w / 2)
|
||
n.y = Math.round(cy - h / 2)
|
||
markDirty() // 尺寸校正不进撤销历史(自动行为)
|
||
}
|
||
function onViewportRest(v) {
|
||
if (
|
||
doc.viewport.x !== v.x || doc.viewport.y !== v.y || doc.viewport.scale !== v.scale
|
||
) {
|
||
doc.viewport = { ...v }
|
||
markDirty()
|
||
}
|
||
}
|
||
function onPanZoom(v) {
|
||
scalePct.value = Math.round(v.scale * 100)
|
||
}
|
||
function onNodeAction({ type, id }) {
|
||
const n = doc.nodes.find((x) => x.id === id)
|
||
if (!n) return
|
||
if (type === 'preview') {
|
||
if (n.type === 'image') previewSrc.value = n.src
|
||
} else if (type === 'duplicate') {
|
||
selectionIds.value = [id]
|
||
duplicateSelection()
|
||
} else if (type === 'delete') {
|
||
selectionIds.value = [id]
|
||
deleteSelection()
|
||
} else if (type === 'front') {
|
||
stage.value?.bumpNode?.(id)
|
||
markDirty()
|
||
}
|
||
}
|
||
|
||
// ================= 快捷键 =================
|
||
|
||
function isTyping(t) {
|
||
return !!(t && t.closest && t.closest('input, textarea, select, [contenteditable="true"]'))
|
||
}
|
||
|
||
function onKeydown(e) {
|
||
if (isTyping(e.target)) {
|
||
if (e.key === 'Escape') e.target.blur()
|
||
return
|
||
}
|
||
const mod = e.ctrlKey || e.metaKey
|
||
if (mod && e.key.toLowerCase() === 'z') {
|
||
e.preventDefault()
|
||
if (e.shiftKey) redo()
|
||
else undo()
|
||
} else if (mod && e.key.toLowerCase() === 'y') {
|
||
e.preventDefault()
|
||
redo()
|
||
} else if (mod && e.key.toLowerCase() === 'a') {
|
||
e.preventDefault()
|
||
stage.value?.selectAll()
|
||
} else if (mod && e.key.toLowerCase() === 'd') {
|
||
e.preventDefault()
|
||
duplicateSelection()
|
||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
e.preventDefault()
|
||
deleteSelection()
|
||
} else if (e.key === 'Escape') {
|
||
stage.value?.cancelLink()
|
||
stage.value?.clearSelection()
|
||
previewSrc.value = ''
|
||
pickerOpen.value = false
|
||
} else if (e.key === '+' || e.key === '=') {
|
||
stage.value?.zoomStep(1)
|
||
} else if (e.key === '-') {
|
||
stage.value?.zoomStep(-1)
|
||
} else if (e.key === '0') {
|
||
stage.value?.setViewport({ x: 0, y: 0, scale: DEFAULT_SCALE })
|
||
} else if (e.key === '1') {
|
||
stage.value?.fitToContent?.()
|
||
}
|
||
}
|
||
|
||
function selectAll() {
|
||
stage.value?.selectAll()
|
||
}
|
||
|
||
// ================= 生命周期 =================
|
||
|
||
onMounted(async () => {
|
||
window.addEventListener('keydown', onKeydown)
|
||
window.addEventListener('paste', onPaste)
|
||
window.addEventListener('beforeunload', flushSync)
|
||
document.addEventListener('visibilitychange', onVisibility)
|
||
const mm = await api('/managed-models')
|
||
allModels.value = mm.data?.data || []
|
||
const first = kindModels.value[0]
|
||
if (first) {
|
||
gen.modelId = first.id
|
||
gen.ratio = (first.ratios && first.ratios[0]) || '1:1'
|
||
}
|
||
await boot()
|
||
})
|
||
|
||
function onVisibility() {
|
||
if (document.visibilityState === 'hidden') flushSync()
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('keydown', onKeydown)
|
||
window.removeEventListener('paste', onPaste)
|
||
window.removeEventListener('beforeunload', flushSync)
|
||
document.removeEventListener('visibilitychange', onVisibility)
|
||
clearTimeout(saveTimer)
|
||
clearTimeout(retryTimer)
|
||
})
|
||
|
||
// 路由离开前确保保存(组件级守卫,避免全局污染)
|
||
onBeforeRouteLeave(async () => {
|
||
if (saveState.value === 'dirty' || saveState.value === 'error') await saveNow()
|
||
return true
|
||
})
|
||
|
||
const saveLabel = computed(() => ({
|
||
saved: '已保存', dirty: '待保存…', saving: '保存中…', error: '保存失败,重试中',
|
||
}[saveState.value] || ''))
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 传送到 body:布局 <main> 自带 relative z-10 层叠上下文,留在里面会被
|
||
站点固定顶栏(z-40)盖住,画布工具栏无法点击。 -->
|
||
<Teleport to="body">
|
||
<div class="cv-page">
|
||
<!-- 顶栏 -->
|
||
<div class="cv-top">
|
||
<div class="cv-top__left">
|
||
<button class="cv-btn" title="返回" @click="$router.push('/user')">←</button>
|
||
<SelectMenu
|
||
class="cv-dd cv-dd--canvas"
|
||
:model-value="currentId"
|
||
:options="canvasOptions"
|
||
placeholder="选择画布"
|
||
@update:model-value="switchCanvas"
|
||
/>
|
||
<button class="cv-btn" title="重命名" @click="renameCanvas">✎</button>
|
||
<button class="cv-btn" title="新建画布" @click="newCanvas">+</button>
|
||
<button class="cv-btn cv-btn--danger" title="删除画布" @click="deleteCanvas">🗑</button>
|
||
</div>
|
||
|
||
<div class="cv-top__right">
|
||
<button class="cv-btn" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
|
||
<button class="cv-btn" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
|
||
<span class="cv-sep" />
|
||
<button class="cv-btn" title="缩小 10%" @click="stage?.zoomStep(-1)">−</button>
|
||
<span class="cv-zoom">{{ scalePct }}%</span>
|
||
<button class="cv-btn" title="放大 10%" @click="stage?.zoomStep(1)">+</button>
|
||
<button class="cv-btn" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: DEFAULT_SCALE })">⌂</button>
|
||
<button class="cv-btn" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
|
||
<span class="cv-sep" />
|
||
<button class="cv-btn cv-btn--accent" @click="addNote">+ 笔记</button>
|
||
<button class="cv-btn cv-btn--accent" @click="openPicker">+ 图片</button>
|
||
<button class="cv-btn" :disabled="uploadBusy" title="上传本地图片(也可直接拖入/粘贴)" @click="fileInput?.click()">
|
||
{{ uploadBusy ? '上传中…' : '⤒ 上传' }}
|
||
</button>
|
||
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onFilePicked">
|
||
<span class="cv-save" :data-state="saveState">{{ saveLabel }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 舞台 + 生成面板 -->
|
||
<div class="cv-body">
|
||
<div
|
||
class="cv-stagewrap"
|
||
@dragover.prevent="dropHover = true"
|
||
@dragleave="dropHover = false"
|
||
@drop.prevent="onDrop"
|
||
>
|
||
<div v-if="dropHover" class="cv-drop">松开把图片放进画布</div>
|
||
<div v-if="loading" class="cv-loading">加载中…</div>
|
||
<div v-else-if="loadError" class="cv-error">{{ loadError }} <button class="cv-btn" @click="boot">重试</button></div>
|
||
<CanvasStage
|
||
v-else
|
||
ref="stage"
|
||
:nodes="doc.nodes"
|
||
:edges="doc.edges"
|
||
@selection="onSelection"
|
||
@move-end="onMoveEnd"
|
||
@link-create="onLinkCreate"
|
||
@note-commit="onNoteCommit"
|
||
@img-size="onImgSize"
|
||
@viewport-rest="onViewportRest"
|
||
@pan-zoom="onPanZoom"
|
||
@node-action="onNodeAction"
|
||
@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">
|
||
<SelectMenu v-model="gen.kind" class="cv-dd" :options="kindOptions" up />
|
||
<SelectMenu
|
||
v-if="kindModels.length"
|
||
v-model="gen.modelId"
|
||
class="cv-dd cv-dd--model"
|
||
:options="modelOptions"
|
||
placeholder="模型"
|
||
up
|
||
/>
|
||
<SelectMenu
|
||
v-if="model && ratios.length"
|
||
v-model="gen.ratio"
|
||
class="cv-dd"
|
||
:options="ratioOptions"
|
||
up
|
||
/>
|
||
<SelectMenu
|
||
v-if="gen.kind === 'image' && model && resolutions.length"
|
||
v-model="gen.resolution"
|
||
class="cv-dd"
|
||
:options="resolutionOptions"
|
||
up
|
||
/>
|
||
<SelectMenu
|
||
v-if="gen.kind === 'video' && model && durations.length"
|
||
v-model="gen.duration"
|
||
class="cv-dd"
|
||
:options="durationOptions"
|
||
up
|
||
/>
|
||
<SelectMenu
|
||
v-if="gen.kind === 'image'"
|
||
v-model.number="gen.count"
|
||
class="cv-dd"
|
||
:options="countOptions"
|
||
up
|
||
/>
|
||
<div v-if="!kindModels.length" class="cv-nomodels">
|
||
⚠ 还没有可用的{{ gen.kind === 'video' ? '视频' : '图像' }}模型
|
||
</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>
|
||
|
||
<!-- 图片预览 -->
|
||
<div v-if="previewSrc" class="cv-lightbox" @click="previewSrc = ''">
|
||
<img :src="generatedUrl(previewSrc)" alt="" @click.stop>
|
||
</div>
|
||
|
||
<!-- 我的图片选择 -->
|
||
<div v-if="pickerOpen" class="cv-picker" @click.self="pickerOpen = false">
|
||
<div class="cv-picker__box">
|
||
<div class="cv-picker__head">
|
||
<span>插入图片(点击放置到画布)</span>
|
||
<div class="cv-picker__acts">
|
||
<button class="cv-btn cv-btn--accent" @click="fileInput?.click()">⤒ 上传本地</button>
|
||
<button class="cv-btn" @click="pickerOpen = false">✕</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="pickerLoading" class="cv-loading">加载中…</div>
|
||
<div v-else-if="!pickerItems.length" class="cv-loading">还没有生成过图片</div>
|
||
<div v-else class="cv-picker__grid">
|
||
<img
|
||
v-for="f in pickerItems"
|
||
:key="f.name"
|
||
:src="thumbUrl(f.name)"
|
||
loading="lazy"
|
||
alt=""
|
||
@click="addImageNode(f.name); pickerOpen = false"
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* ===== 画布:玻璃拟态全屏工作台(随站点明暗主题走变量) ===== */
|
||
.cv-btn {
|
||
padding: 5px 11px;
|
||
border-radius: 9px;
|
||
border: 1px solid var(--ink-soft);
|
||
background: var(--surface);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
backdrop-filter: blur(10px);
|
||
color: var(--fg);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
box-shadow: var(--glass-shadow);
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease, border-color 0.12s ease;
|
||
}
|
||
.cv-btn:hover:not(:disabled) {
|
||
background: var(--hover);
|
||
border-color: rgb(124 92 252 / 0.4);
|
||
}
|
||
.cv-btn:active:not(:disabled) {
|
||
transform: translateY(1px);
|
||
}
|
||
.cv-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
.cv-btn--danger:hover:not(:disabled) {
|
||
background: #ffb4ab;
|
||
}
|
||
.cv-btn--accent {
|
||
background: rgb(34 211 238 / 0.16);
|
||
border-color: rgb(34 211 238 / 0.4);
|
||
}
|
||
.cv-btn--accent:hover:not(:disabled) {
|
||
background: rgb(52 211 153 / 0.16);
|
||
border-color: rgb(52 211 153 / 0.4);
|
||
}
|
||
.cv-select,
|
||
.cv-input {
|
||
padding: 6px 10px;
|
||
border-radius: 9px;
|
||
border: 1px solid var(--ink-soft);
|
||
background: var(--surface);
|
||
color: var(--fg);
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
.cv-select:focus,
|
||
.cv-input:focus {
|
||
border-color: rgb(124 92 252 / 0.6);
|
||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
||
}
|
||
|
||
.cv-page {
|
||
position: fixed;
|
||
inset: 0;
|
||
/* 站点顶栏是 z-40;画布全屏接管时必须盖住它 */
|
||
z-index: 50;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: var(--app-bg);
|
||
}
|
||
.cv-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 9px 14px;
|
||
border-bottom: 1px solid var(--ink-soft);
|
||
background: var(--surface);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
backdrop-filter: blur(14px);
|
||
flex-wrap: wrap;
|
||
}
|
||
.cv-top__left,
|
||
.cv-top__right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
flex-wrap: wrap;
|
||
}
|
||
/* 统一自定义下拉的宽度/紧凑度(SelectMenu 内部是 .field 按钮) */
|
||
.cv-dd {
|
||
min-width: 96px;
|
||
}
|
||
.cv-dd--canvas {
|
||
width: 200px;
|
||
min-width: 0;
|
||
}
|
||
.cv-dd :deep(.field) {
|
||
height: 34px;
|
||
padding: 0 10px;
|
||
font-size: 12.5px;
|
||
}
|
||
.cv-dd--canvas :deep(.field) {
|
||
height: 30px;
|
||
}
|
||
.cv-dd--zoom {
|
||
display: none;
|
||
}
|
||
.cv-sep {
|
||
width: 2.5px;
|
||
height: 20px;
|
||
border-radius: 2px;
|
||
background: var(--ink);
|
||
opacity: 0.25;
|
||
margin: 0 4px;
|
||
}
|
||
.cv-zoom {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--fg);
|
||
min-width: 46px;
|
||
text-align: center;
|
||
background: var(--hover);
|
||
border: 1px solid rgb(124 92 252 / 0.35);
|
||
border-radius: 999px;
|
||
padding: 1px 8px;
|
||
}
|
||
.cv-save {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--fg-3);
|
||
margin-left: 8px;
|
||
}
|
||
.cv-save[data-state='saved'] {
|
||
color: #15803d;
|
||
}
|
||
.cv-save[data-state='dirty'],
|
||
.cv-save[data-state='saving'] {
|
||
color: var(--m-orange);
|
||
}
|
||
.cv-save[data-state='error'] {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.cv-body {
|
||
flex: 1;
|
||
display: flex;
|
||
min-height: 0;
|
||
}
|
||
.cv-stagewrap {
|
||
position: relative;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.cv-drop {
|
||
position: absolute;
|
||
inset: 10px;
|
||
z-index: 6;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
color: var(--fg);
|
||
border: 2px dashed rgb(34 211 238 / 0.6);
|
||
border-radius: 18px;
|
||
background: rgb(34 211 238 / 0.1);
|
||
pointer-events: none;
|
||
}
|
||
.cv-loading,
|
||
.cv-error {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
color: var(--fg-2);
|
||
font-weight: 600;
|
||
background: var(--app-bg);
|
||
z-index: 5;
|
||
}
|
||
.cv-error {
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* 底部居中的生成输入框 —— 悬浮在画布上 */
|
||
.cv-dock {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 14px;
|
||
transform: translateX(-50%);
|
||
width: min(720px, calc(100% - 28px));
|
||
z-index: 8;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.cv-ratios {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.cv-chip {
|
||
padding: 3px 11px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--ink-soft);
|
||
background: var(--surface);
|
||
color: var(--fg);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: background 0.13s ease, border-color 0.13s ease, box-shadow 0.13s ease;
|
||
}
|
||
.cv-chip:hover {
|
||
background: var(--hover);
|
||
border-color: rgb(124 92 252 / 0.4);
|
||
}
|
||
.cv-chip.is-on {
|
||
color: #fff;
|
||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
||
border-color: transparent;
|
||
box-shadow: 0 4px 14px var(--accent-glow);
|
||
}
|
||
/* 生成输入框:参考图缩略图在上,文本框居中,控制条在下 */
|
||
.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: visible; /* 下拉面板要从输入框上方弹出,不能被裁切 */
|
||
}
|
||
.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;
|
||
color: var(--fg-3);
|
||
}
|
||
.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);
|
||
border-radius: 0 0 19px 19px; /* composer 不再裁切,圆角由最底层行补齐 */
|
||
}
|
||
.cv-composer__bar .cv-dd--model {
|
||
flex: 1;
|
||
min-width: 140px;
|
||
max-width: 260px;
|
||
}
|
||
.cv-composer__bar .cv-gen {
|
||
margin-left: auto;
|
||
padding: 7px 24px;
|
||
}
|
||
.cv-gen {
|
||
padding: 10px;
|
||
border-radius: 11px;
|
||
border: 1px solid rgb(255 255 255 / 0.18);
|
||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
box-shadow: 0 8px 22px var(--accent-glow);
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
|
||
}
|
||
.cv-gen:hover:not(:disabled) {
|
||
transform: translateY(-1px);
|
||
filter: brightness(1.07);
|
||
box-shadow: 0 12px 30px var(--accent-glow);
|
||
}
|
||
.cv-gen:active:not(:disabled) {
|
||
transform: translateY(1px) scale(0.99);
|
||
box-shadow: 0 4px 12px var(--accent-glow);
|
||
}
|
||
.cv-gen:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
.cv-genmsg {
|
||
font-size: 11px;
|
||
color: var(--fg-2);
|
||
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 {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #92400e;
|
||
line-height: 1.6;
|
||
border: 1px dashed rgb(245 158 11 / 0.6);
|
||
border-radius: 9px;
|
||
background: rgb(245 158 11 / 0.12);
|
||
padding: 8px 10px;
|
||
}
|
||
.cv-dock .cv-nomodels {
|
||
flex: 1;
|
||
}
|
||
|
||
.cv-lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgb(17 17 17 / 0.75);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
cursor: zoom-out;
|
||
}
|
||
.cv-lightbox img {
|
||
max-width: 92vw;
|
||
max-height: 92vh;
|
||
border-radius: 12px;
|
||
border: 1px solid rgb(255 255 255 / 0.5);
|
||
box-shadow: 0 24px 60px rgb(0 0 0 / 0.5), 0 0 40px var(--accent-glow);
|
||
}
|
||
|
||
.cv-picker {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgb(17 17 17 / 0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 90;
|
||
}
|
||
.cv-picker__box {
|
||
width: min(720px, 92vw);
|
||
max-height: 80vh;
|
||
background: var(--menu-bg);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
backdrop-filter: blur(20px);
|
||
border: 1px solid var(--glass-border);
|
||
border-radius: 16px;
|
||
box-shadow: var(--glass-shadow-lg);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.cv-picker__head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
color: var(--fg);
|
||
}
|
||
.cv-picker__acts {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.cv-picker__grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||
gap: 10px;
|
||
overflow-y: auto;
|
||
}
|
||
.cv-picker__grid img {
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
object-fit: cover;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--ink-soft);
|
||
cursor: pointer;
|
||
background: var(--surface-2);
|
||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||
}
|
||
.cv-picker__grid img:hover {
|
||
transform: translateY(-2px);
|
||
border-color: rgb(124 92 252 / 0.55);
|
||
box-shadow: 0 8px 20px var(--accent-glow);
|
||
}
|
||
</style>
|