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 ...@@ -6,9 +6,14 @@ QML 调 imageGen.submitTask(prompt, refs, aspect, size, mode) → 桥层把 mode
6 然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。 6 然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。
7 """ 7 """
8 import logging 8 import logging
9 import tempfile
10 import time
11 import uuid
12 from pathlib import Path
9 from typing import Optional 13 from typing import Optional
10 14
11 from PySide6.QtCore import Property, QObject, Signal, Slot 15 from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
16 from PySide6.QtGui import QGuiApplication
12 17
13 from core.generation import MODEL_BY_MODE, MODEL_PRO 18 from core.generation import MODEL_BY_MODE, MODEL_PRO
14 19
...@@ -89,6 +94,47 @@ class ImageGenBridge(QObject): ...@@ -89,6 +94,47 @@ class ImageGenBridge(QObject):
89 self._tqm.cancel_task(task_id) 94 self._tqm.cancel_task(task_id)
90 self.busyChanged.emit() 95 self.busyChanged.emit()
91 96
97 @Slot(result=str)
98 def pasteFromClipboard(self) -> str:
99 """从系统剪贴板拿图片,存到 temp 目录,返回本地路径。
100
101 失败(剪贴板无图)返回空字符串。tempdir 启动期已被
102 _cleanup_clipboard_tempfiles 清过 24h+ 旧文件。
103 """
104 clipboard = QGuiApplication.clipboard()
105 if clipboard is None:
106 return ""
107 image = clipboard.image()
108 if image.isNull():
109 return ""
110
111 tmp_dir = Path(tempfile.gettempdir()) / "nano_banana_app"
112 tmp_dir.mkdir(parents=True, exist_ok=True)
113 ts = time.strftime("%Y%m%d_%H%M%S")
114 path = tmp_dir / f"clipboard_{ts}_{uuid.uuid4().hex[:6]}.png"
115 if not image.save(str(path), "PNG"):
116 self._logger.warning(f"剪贴板图片保存失败: {path}")
117 return ""
118 self._logger.info(f"剪贴板图片已存: {path}")
119 return str(path)
120
121 @Slot("QVariantList", result="QVariantList")
122 def normalizeFileUrls(self, urls) -> list:
123 """QML DropArea 给的是 file:/// QUrl 列表,转成本地路径字符串列表(过滤非图片)。"""
124 result = []
125 valid_ext = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
126 for u in urls or []:
127 try:
128 if isinstance(u, QUrl):
129 p = u.toLocalFile()
130 else:
131 p = QUrl(str(u)).toLocalFile() or str(u)
132 if p and Path(p).suffix.lower() in valid_ext:
133 result.append(p)
134 except Exception:
135 continue
136 return result
137
92 # ---- 内部信号转发 ---------------------------------------------------- 138 # ---- 内部信号转发 ----------------------------------------------------
93 139
94 def _on_task_added(self, task) -> None: 140 def _on_task_added(self, task) -> None:
......
1 import QtQuick 1 import QtQuick
2 import QtQuick.Controls.Basic 2 import QtQuick.Controls.Basic
3 import QtQuick.Dialogs
3 import QtQuick.Layouts 4 import QtQuick.Layouts
4 import "components" 5 import "components"
5 import "." as App 6 import "." as App
...@@ -8,12 +9,36 @@ Item { ...@@ -8,12 +9,36 @@ Item {
8 id: tab 9 id: tab
9 10
10 // ===== 状态 ===== 11 // ===== 状态 =====
11 property var refImages: [] // list[str] 参考图本地路径,task #14b 接上传/粘贴/拖拽 12 property var refImages: [] // list[str] 参考图本地路径
12 property string currentTaskId: "" // 当前进行中的任务 id(同一时刻最多一个) 13 property string currentTaskId: "" // 当前进行中的任务 id(同一时刻最多一个)
13 property string lastResultPath: "" // 最近一次 taskCompleted 的图片路径 14 property string lastResultPath: "" // 最近一次 taskCompleted 的图片路径
14 property string statusText: "● 就绪" 15 property string statusText: "● 就绪"
15 property color statusColor: App.Theme.success 16 property color statusColor: App.Theme.success
16 17
18 function addRefPath(p) {
19 if (!p) return
20 if (tab.refImages.indexOf(p) >= 0) return // 去重
21 tab.refImages = tab.refImages.concat([p])
22 }
23 function addRefPaths(paths) {
24 for (var i = 0; i < (paths || []).length; i++) {
25 addRefPath(paths[i])
26 }
27 }
28 function removeRefAt(idx) {
29 var copy = tab.refImages.slice()
30 copy.splice(idx, 1)
31 tab.refImages = copy
32 }
33
34 FileDialog {
35 id: addImageDialog
36 title: "选择参考图片"
37 fileMode: FileDialog.OpenFiles
38 nameFilters: ["图片 (*.png *.jpg *.jpeg *.webp *.bmp)"]
39 onAccepted: tab.addRefPaths(imageGen.normalizeFileUrls(selectedFiles))
40 }
41
17 // ===== 桥层信号 ===== 42 // ===== 桥层信号 =====
18 Connections { 43 Connections {
19 target: imageGen 44 target: imageGen
...@@ -94,8 +119,21 @@ Item { ...@@ -94,8 +119,21 @@ Item {
94 119
95 RowLayout { 120 RowLayout {
96 spacing: App.Theme.space2 121 spacing: App.Theme.space2
97 SecondaryButton { text: "添加图片"; enabled: false } 122 SecondaryButton {
98 SecondaryButton { text: "📋 粘贴图片"; enabled: false } 123 text: "添加图片"
124 onClicked: addImageDialog.open()
125 }
126 SecondaryButton {
127 text: "📋 粘贴图片"
128 onClicked: {
129 var p = imageGen.pasteFromClipboard()
130 if (p) tab.addRefPath(p)
131 else {
132 tab.statusText = "● 剪贴板没有图片"
133 tab.statusColor = App.Theme.warning
134 }
135 }
136 }
99 Label { 137 Label {
100 text: "已选择 " + tab.refImages.length + " 张" 138 text: "已选择 " + tab.refImages.length + " 张"
101 font.family: App.Theme.fontFamily 139 font.family: App.Theme.fontFamily
...@@ -112,12 +150,13 @@ Item { ...@@ -112,12 +150,13 @@ Item {
112 } 150 }
113 151
114 Rectangle { 152 Rectangle {
153 id: dropZone
115 Layout.fillWidth: true 154 Layout.fillWidth: true
116 Layout.fillHeight: true 155 Layout.fillHeight: true
117 color: App.Theme.bgSubtle 156 color: dropArea.containsDrag ? App.Theme.accentSubtle : App.Theme.bgSubtle
118 radius: App.Theme.radiusMd 157 radius: App.Theme.radiusMd
119 border.width: 2 158 border.width: 2
120 border.color: App.Theme.borderDefault 159 border.color: dropArea.containsDrag ? App.Theme.accent : App.Theme.borderDefault
121 160
122 Text { 161 Text {
123 anchors.centerIn: parent 162 anchors.centerIn: parent
...@@ -125,6 +164,76 @@ Item { ...@@ -125,6 +164,76 @@ Item {
125 color: App.Theme.textTertiary 164 color: App.Theme.textTertiary
126 font.family: App.Theme.fontFamily 165 font.family: App.Theme.fontFamily
127 font.pointSize: App.Theme.fontSm 166 font.pointSize: App.Theme.fontSm
167 visible: tab.refImages.length === 0
168 }
169
170 // 缩略图 Flow(有图时显示)
171 ScrollView {
172 anchors.fill: parent
173 anchors.margins: App.Theme.space2
174 clip: true
175 visible: tab.refImages.length > 0
176
177 Flow {
178 width: dropZone.width - App.Theme.space2 * 2
179 spacing: App.Theme.space2
180
181 Repeater {
182 model: tab.refImages
183 delegate: Rectangle {
184 width: 96
185 height: 96
186 radius: App.Theme.radiusSm
187 color: App.Theme.bgSurface
188 border.width: 1
189 border.color: App.Theme.borderDefault
190
191 Image {
192 anchors.fill: parent
193 anchors.margins: 2
194 source: "file:///" + modelData
195 fillMode: Image.PreserveAspectCrop
196 smooth: true
197 asynchronous: true
198 }
199
200 // 右上角删除按钮
201 Rectangle {
202 width: 20; height: 20; radius: 10
203 color: App.Theme.danger
204 anchors.top: parent.top
205 anchors.right: parent.right
206 anchors.margins: 2
207
208 Text {
209 anchors.centerIn: parent
210 text: "×"
211 color: "white"
212 font.pixelSize: 14
213 font.weight: Font.Bold
214 }
215
216 MouseArea {
217 anchors.fill: parent
218 cursorShape: Qt.PointingHandCursor
219 onClicked: tab.removeRefAt(index)
220 }
221 }
222 }
223 }
224 }
225 }
226
227 DropArea {
228 id: dropArea
229 anchors.fill: parent
230 keys: ["text/uri-list"]
231 onDropped: function(drop) {
232 if (drop.urls && drop.urls.length > 0) {
233 tab.addRefPaths(imageGen.normalizeFileUrls(drop.urls))
234 drop.acceptProposedAction()
235 }
236 }
128 } 237 }
129 } 238 }
130 } 239 }
......