ca43df8e by 柴进

feat(qml): task #14b 参考图录入(添加 / 粘贴 / 拖拽)

ImageGenBridge 加 2 个 Slot:
- pasteFromClipboard() → str
  从 QGuiApplication.clipboard() 拿 image,存到 tempdir/nano_banana_app/clipboard_*.png
  (与旧 _cleanup_clipboard_tempfiles 同目录,启动期统一 24h 清理)
  失败返回 ""
- normalizeFileUrls(urls list) → list
  把 QML DropArea 给的 file:/// QUrl 列表转本地路径,过滤非图扩展名
  (.png/.jpg/.jpeg/.webp/.bmp)

ImageGenTab.qml:
- import QtQuick.Dialogs 引入 FileDialog (现代 QML6 module)
- "添加图片" → addImageDialog.open() (multi-select 图片)
- ":clipboard: 粘贴图片" → imageGen.pasteFromClipboard(),无图时状态变橙色"剪贴板没有图片"
- 拖拽区 DropArea 接 text/uri-list,containsDrag 时边框变 accent + 底色变 accentSubtle
  drop 后调 imageGen.normalizeFileUrls(drop.urls) 拿本地路径
- 已选图缩略图 Flow:
  ScrollView + Repeater 96×96 圆角缩略图 + 右上角红色 × 删除按钮
- addRefPath/addRefPaths/removeRefAt 三个辅助函数处理状态去重和增量

未做(task #14c/d):
- 提示词收藏 / 删除 saved_prompts 持久化
- 下载图片 + 双击预览打开系统查看器

视觉验证:QML_AUTO_LOGIN=1 启动主窗口,参考图区按钮全 enabled,UI 无回归。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 257c4a71
......@@ -6,9 +6,14 @@ QML 调 imageGen.submitTask(prompt, refs, aspect, size, mode) → 桥层把 mode
然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。
"""
import logging
import tempfile
import time
import uuid
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
from PySide6.QtGui import QGuiApplication
from core.generation import MODEL_BY_MODE, MODEL_PRO
......@@ -89,6 +94,47 @@ class ImageGenBridge(QObject):
self._tqm.cancel_task(task_id)
self.busyChanged.emit()
@Slot(result=str)
def pasteFromClipboard(self) -> str:
"""从系统剪贴板拿图片,存到 temp 目录,返回本地路径。
失败(剪贴板无图)返回空字符串。tempdir 启动期已被
_cleanup_clipboard_tempfiles 清过 24h+ 旧文件。
"""
clipboard = QGuiApplication.clipboard()
if clipboard is None:
return ""
image = clipboard.image()
if image.isNull():
return ""
tmp_dir = Path(tempfile.gettempdir()) / "nano_banana_app"
tmp_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = tmp_dir / f"clipboard_{ts}_{uuid.uuid4().hex[:6]}.png"
if not image.save(str(path), "PNG"):
self._logger.warning(f"剪贴板图片保存失败: {path}")
return ""
self._logger.info(f"剪贴板图片已存: {path}")
return str(path)
@Slot("QVariantList", result="QVariantList")
def normalizeFileUrls(self, urls) -> list:
"""QML DropArea 给的是 file:/// QUrl 列表,转成本地路径字符串列表(过滤非图片)。"""
result = []
valid_ext = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
for u in urls or []:
try:
if isinstance(u, QUrl):
p = u.toLocalFile()
else:
p = QUrl(str(u)).toLocalFile() or str(u)
if p and Path(p).suffix.lower() in valid_ext:
result.append(p)
except Exception:
continue
return result
# ---- 内部信号转发 ----------------------------------------------------
def _on_task_added(self, task) -> None:
......
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Dialogs
import QtQuick.Layouts
import "components"
import "." as App
......@@ -8,12 +9,36 @@ Item {
id: tab
// ===== 状态 =====
property var refImages: [] // list[str] 参考图本地路径,task #14b 接上传/粘贴/拖拽
property var refImages: [] // list[str] 参考图本地路径
property string currentTaskId: "" // 当前进行中的任务 id(同一时刻最多一个)
property string lastResultPath: "" // 最近一次 taskCompleted 的图片路径
property string statusText: "● 就绪"
property color statusColor: App.Theme.success
function addRefPath(p) {
if (!p) return
if (tab.refImages.indexOf(p) >= 0) return // 去重
tab.refImages = tab.refImages.concat([p])
}
function addRefPaths(paths) {
for (var i = 0; i < (paths || []).length; i++) {
addRefPath(paths[i])
}
}
function removeRefAt(idx) {
var copy = tab.refImages.slice()
copy.splice(idx, 1)
tab.refImages = copy
}
FileDialog {
id: addImageDialog
title: "选择参考图片"
fileMode: FileDialog.OpenFiles
nameFilters: ["图片 (*.png *.jpg *.jpeg *.webp *.bmp)"]
onAccepted: tab.addRefPaths(imageGen.normalizeFileUrls(selectedFiles))
}
// ===== 桥层信号 =====
Connections {
target: imageGen
......@@ -94,8 +119,21 @@ Item {
RowLayout {
spacing: App.Theme.space2
SecondaryButton { text: "添加图片"; enabled: false }
SecondaryButton { text: "📋 粘贴图片"; enabled: false }
SecondaryButton {
text: "添加图片"
onClicked: addImageDialog.open()
}
SecondaryButton {
text: "📋 粘贴图片"
onClicked: {
var p = imageGen.pasteFromClipboard()
if (p) tab.addRefPath(p)
else {
tab.statusText = "● 剪贴板没有图片"
tab.statusColor = App.Theme.warning
}
}
}
Label {
text: "已选择 " + tab.refImages.length + " 张"
font.family: App.Theme.fontFamily
......@@ -112,12 +150,13 @@ Item {
}
Rectangle {
id: dropZone
Layout.fillWidth: true
Layout.fillHeight: true
color: App.Theme.bgSubtle
color: dropArea.containsDrag ? App.Theme.accentSubtle : App.Theme.bgSubtle
radius: App.Theme.radiusMd
border.width: 2
border.color: App.Theme.borderDefault
border.color: dropArea.containsDrag ? App.Theme.accent : App.Theme.borderDefault
Text {
anchors.centerIn: parent
......@@ -125,6 +164,76 @@ Item {
color: App.Theme.textTertiary
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
visible: tab.refImages.length === 0
}
// 缩略图 Flow(有图时显示)
ScrollView {
anchors.fill: parent
anchors.margins: App.Theme.space2
clip: true
visible: tab.refImages.length > 0
Flow {
width: dropZone.width - App.Theme.space2 * 2
spacing: App.Theme.space2
Repeater {
model: tab.refImages
delegate: Rectangle {
width: 96
height: 96
radius: App.Theme.radiusSm
color: App.Theme.bgSurface
border.width: 1
border.color: App.Theme.borderDefault
Image {
anchors.fill: parent
anchors.margins: 2
source: "file:///" + modelData
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
}
// 右上角删除按钮
Rectangle {
width: 20; height: 20; radius: 10
color: App.Theme.danger
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 2
Text {
anchors.centerIn: parent
text: "×"
color: "white"
font.pixelSize: 14
font.weight: Font.Bold
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: tab.removeRefAt(index)
}
}
}
}
}
}
DropArea {
id: dropArea
anchors.fill: parent
keys: ["text/uri-list"]
onDropped: function(drop) {
if (drop.urls && drop.urls.length > 0) {
tab.addRefPaths(imageGen.normalizeFileUrls(drop.urls))
drop.acceptProposedAction()
}
}
}
}
}
......