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 图片) - "粘贴图片" → 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>
Showing
2 changed files
with
161 additions
and
6 deletions
| ... | @@ -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 | } | ... | ... |
-
Please register or sign in to post a comment