ImageGenTab.qml 36.3 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Dialogs
import QtQuick.Layouts
import "components"
import "." as App

Item {
    id: tab

    // ===== 状态 =====
    property var refImages: []           // list[str] 参考图本地路径
    property var myTaskIds: []           // 我提交过且还在跑/排队的 task id(支持多任务)
    property string lastTaskId: ""       // 最近一次提交的 task — 状态 / 进度显示锚点
    property string lastResultPath: ""   // 最近一次 taskCompleted 的图片路径
    property string statusText: "● 就绪"
    property color statusColor: App.Theme.success

    // FLASH_ONLY_ASPECT_RATIOS — 与 core/generation.py 保持一致;这些比例仅极速模式支持
    readonly property var flashOnlyAspectRatios: ["1:4", "4:1", "1:8", "8:1"]

    function isMyTask(tid) {
        return tab.myTaskIds.indexOf(tid) >= 0
    }
    function dropMyTask(tid) {
        var copy = tab.myTaskIds.slice()
        var i = copy.indexOf(tid)
        if (i >= 0) copy.splice(i, 1)
        tab.myTaskIds = copy
    }
    function isFlashOnly(ratio) {
        return tab.flashOnlyAspectRatios.indexOf(ratio) >= 0
    }

    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
    }
    function reorderRefAt(from, to) {
        // pop + insert,与旧 reorder_image(QWidget 版) 等价
        if (from === to) return
        var copy = tab.refImages.slice()
        if (from < 0 || from >= copy.length) return
        if (to < 0) to = 0
        if (to >= copy.length) to = copy.length - 1
        var item = copy.splice(from, 1)[0]
        copy.splice(to, 0, item)
        tab.refImages = copy
    }

    // 拖拽中的源 index(-1 表示无拖拽进行中),用来给 source delegate 高亮
    property int draggingIndex: -1
    // 拖拽过程中鼠标 hover 到的目标 index(-1 表示当前不在任何 delegate 上)
    // 只有 release 时才把 draggingIndex 真正 reorder 到这里
    property int dropTargetIndex: -1
    // 拖拽中鼠标坐标(相对 dropZone),驱动 ghost 跟手
    property real dragGhostX: -1
    property real dragGhostY: -1

    FileDialog {
        id: addImageDialog
        title: "选择参考图片"
        fileMode: FileDialog.OpenFiles
        nameFilters: ["图片 (*.png *.jpg *.jpeg *.webp *.bmp)"]
        onAccepted: tab.addUrlsValidated(selectedFiles, "图片")
    }

    // 统一的"加 + 校验 + 反馈"流程,让用户知道是否有图被 10MB / 损坏拦截
    function addUrlsValidated(urls, srcLabel) {
        if (!urls || urls.length === 0) return
        var paths = imageGen.normalizeFileUrls(urls)  // 内部已过滤扩展名 + 10MB + QPixmap 完整性
        var dropped = urls.length - paths.length
        if (paths.length === 0) {
            tab.statusText = "● 所有 " + urls.length + " 个" + srcLabel + "都校验失败(不支持的格式 / 超 10MB / 损坏)"
            tab.statusColor = App.Theme.danger
            return
        }
        tab.addRefPaths(paths)
        if (dropped > 0) {
            tab.statusText = "● 已添加 " + paths.length + " 张,丢弃 " + dropped + " 张(不支持 / 超 10MB / 损坏)"
            tab.statusColor = App.Theme.warning
        } else {
            tab.statusText = "● 已添加 " + paths.length + " 张" + srcLabel
            tab.statusColor = App.Theme.success
        }
    }

    function pasteFromClipboardAction() {
        var ps = imageGen.pasteFromClipboard()
        if (!ps || ps.length === 0) {
            tab.statusText = "● 剪贴板没有图片"
            tab.statusColor = App.Theme.warning
            return
        }
        // 路径 A 拿到的可能是用户复制的本地文件,要校验大小;
        // 路径 B/C 是 Qt 写入 temp 的 PNG,必定通过校验
        var validated = imageGen.validateImageFiles(ps)
        var dropped = ps.length - validated.length
        if (validated.length === 0) {
            tab.statusText = "● 剪贴板图片校验失败(>10MB 或损坏)"
            tab.statusColor = App.Theme.danger
            return
        }
        tab.addRefPaths(validated)
        if (dropped > 0) {
            tab.statusText = "● 已粘贴 " + validated.length + " 张,丢弃 " + dropped + " 张(>10MB 或损坏)"
            tab.statusColor = App.Theme.warning
        } else {
            tab.statusText = "● 已粘贴 " + validated.length + " 张图片"
            tab.statusColor = App.Theme.success
        }
    }

    // 全局 Ctrl+V 粘贴图片(仅在图片生成 tab 激活时生效;
    // 焦点在 TextArea/TextField 里 Ctrl+V 优先走文本粘贴,不会触发这个 Shortcut)
    Shortcut {
        sequences: [StandardKey.Paste]
        enabled: appState.currentTab === 0
        onActivated: tab.pasteFromClipboardAction()
    }

    FileDialog {
        id: saveImageDialog
        title: "保存生成的图片"
        fileMode: FileDialog.SaveFile
        nameFilters: ["PNG 图片 (*.png)"]
        defaultSuffix: "png"
        onAccepted: {
            var dest = selectedFile.toString().replace("file:///", "")
            var ok = imageGen.saveFile(tab.lastResultPath, dest)
            if (ok) {
                tab.statusText = "● 已保存到 " + dest
                tab.statusColor = App.Theme.success
            } else {
                tab.statusText = "● 保存失败"
                tab.statusColor = App.Theme.danger
            }
        }
    }

    // sidebar 点击任务项 → 回填到本 tab(旧 _load_task_to_main_window 等价)
    Connections {
        target: taskQueue
        function onTaskLoadRequested(payload) {
            if (!payload || payload.type !== "image_gen") return
            appState.setTab(0)  // 切到图片生成 tab

            promptArea.text = payload.prompt || ""
            tab.refImages = (payload.referenceImages || []).slice()

            var ai = aspectCombo.find(payload.aspectRatio || "")
            if (ai >= 0) aspectCombo.currentIndex = ai
            var si = sizeCombo.find(payload.imageSize || "")
            if (si >= 0) sizeCombo.currentIndex = si
            var mi = modeCombo.find(payload.mode || "")
            if (mi >= 0) modeCombo.currentIndex = mi

            // 已完成任务回显结果图(payload.resultPath 仅 COMPLETED 时有)
            if (payload.resultPath) {
                tab.lastResultPath = payload.resultPath
            }

            tab.statusText = "● 已加载任务 " + (payload.taskId || "").substring(0, 8)
            tab.statusColor = App.Theme.accent
        }
    }

    // 历史 tab 点"🔁 重做" → 回填提示词 + 参考图 + 设置到本 tab,但不回填生成图
    Connections {
        target: history
        function onRedoRequested(payload) {
            if (!payload) return
            appState.setTab(0)  // 切到图片生成 tab

            promptArea.text = payload.prompt || ""
            tab.refImages = (payload.referenceImages || []).slice()

            var ai = aspectCombo.find(payload.aspectRatio || "")
            if (ai >= 0) aspectCombo.currentIndex = ai
            var si = sizeCombo.find(payload.imageSize || "")
            if (si >= 0) sizeCombo.currentIndex = si
            var mi = modeCombo.find(payload.mode || "")
            if (mi >= 0) modeCombo.currentIndex = mi

            // 重做不回填生成图,给用户一个干净的预览区开始
            tab.lastResultPath = ""

            tab.statusText = "● 已载入历史,可微调提示词后重新生成"
            tab.statusColor = App.Theme.accent
        }
    }

    // ===== 桥层信号 =====
    Connections {
        target: imageGen

        // 进度只显示"最后提交的那一条"(最相关)
        function onTaskProgress(taskId, progress, msg) {
            if (taskId !== tab.lastTaskId) return
            tab.statusText = "● " + (msg || "生成中…")
            tab.statusColor = App.Theme.accent
        }

        // 任何我提交过的 task 完成都更新预览(覆盖式 — 最后完成的优先)
        function onTaskCompleted(taskId, resultPath, prompt, model) {
            if (!tab.isMyTask(taskId)) return
            tab.lastResultPath = resultPath
            tab.dropMyTask(taskId)
            if (tab.lastTaskId === taskId) tab.lastTaskId = ""
            if (tab.myTaskIds.length === 0) {
                tab.statusText = "● 已完成"
            } else {
                tab.statusText = "● 已完成(队列还剩 " + tab.myTaskIds.length + " 条)"
            }
            tab.statusColor = App.Theme.success
        }

        function onTaskFailed(taskId, error) {
            if (!tab.isMyTask(taskId)) return
            tab.dropMyTask(taskId)
            if (tab.lastTaskId === taskId) tab.lastTaskId = ""
            tab.statusText = "● " + (error || "失败")
            tab.statusColor = App.Theme.danger
        }
    }

    function submit() {
        if (promptArea.text.trim().length === 0) {
            tab.statusText = "● 请输入提示词"
            tab.statusColor = App.Theme.danger
            return
        }
        try {
            var taskId = imageGen.submitTask(
                promptArea.text.trim(),
                tab.refImages,
                aspectCombo.currentText,
                sizeCombo.currentText,
                modeCombo.currentText
            )
            tab.myTaskIds = tab.myTaskIds.concat([taskId])
            tab.lastTaskId = taskId
            tab.statusText = "● 已提交(我的队列 " + tab.myTaskIds.length + " 条)"
            tab.statusColor = App.Theme.accent
        } catch (e) {
            // TaskQueueManager 队列满(>10)时抛 RuntimeError,提示给用户
            tab.statusText = "● " + e
            tab.statusColor = App.Theme.danger
        }
    }

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: App.Theme.space5
        spacing: App.Theme.space4

        // ===== 参考图片卡片 =====
        Card {
            Layout.fillWidth: true
            Layout.preferredHeight: 230
            Layout.minimumHeight: 200

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: App.Theme.space4
                spacing: App.Theme.space3

                RowLayout {
                    spacing: App.Theme.space3
                    CaptionLabel {
                        text: "参考图片"
                        font.pointSize: App.Theme.fontLg
                    }
                    Item { Layout.fillWidth: true }
                }

                RowLayout {
                    spacing: App.Theme.space2
                    SecondaryButton {
                        text: "添加图片"
                        onClicked: addImageDialog.open()
                    }
                    SecondaryButton {
                        text: "📋 粘贴图片"
                        onClicked: tab.pasteFromClipboardAction()
                    }
                    Label {
                        text: "已选择 " + tab.refImages.length + " 张"
                        font.family: App.Theme.fontFamily
                        font.pointSize: App.Theme.fontSm
                        color: App.Theme.textSecondary
                    }
                    Label {
                        text: "💡 拖拽或粘贴图片到下方区域"
                        font.family: App.Theme.fontFamily
                        font.pointSize: App.Theme.fontSm
                        color: App.Theme.textTertiary
                    }
                    Item { Layout.fillWidth: true }
                }

                Rectangle {
                    id: dropZone
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    color: dropArea.containsDrag ? App.Theme.accentSubtle : App.Theme.bgSubtle
                    radius: App.Theme.radiusMd
                    border.width: 2
                    border.color: dropArea.containsDrag ? App.Theme.accent : App.Theme.borderDefault

                    Text {
                        anchors.centerIn: parent
                        text: "拖拽图片到这里"
                        color: App.Theme.textTertiary
                        font.family: App.Theme.fontFamily
                        font.pointSize: App.Theme.fontSm
                        visible: tab.refImages.length === 0
                    }

                    // 缩略图横向 ListView(与旧版 QHBoxLayout 一致),支持内部拖拽重排
                    ListView {
                        id: refList
                        anchors.fill: parent
                        anchors.margins: App.Theme.space2
                        orientation: ListView.Horizontal
                        spacing: App.Theme.space2
                        clip: true
                        interactive: false   // 禁用 flick,让外部 ScrollBar 控制
                        visible: tab.refImages.length > 0
                        model: tab.refImages

                        ScrollBar.horizontal: ScrollBar {
                            policy: refList.contentWidth > refList.width
                                ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
                        }

                        delegate: Rectangle {
                            id: thumb
                            width: 96
                            height: 96
                            radius: App.Theme.radiusSm
                            // 三态视觉:
                            //   源(draggingIndex):scale 1.08 + accent 边
                            //   drop target:accent 边 + accentSubtle 底色("放这里"指示)
                            //   普通:默认
                            readonly property bool isSource: tab.draggingIndex === index
                            readonly property bool isDropTarget: tab.dropTargetIndex === index && tab.draggingIndex >= 0 && tab.draggingIndex !== index

                            color: isDropTarget ? App.Theme.accentSubtle : App.Theme.bgSurface
                            scale: isSource ? 1.08 : 1.0
                            border.width: (isSource || isDropTarget) ? 3 : 1
                            border.color: (isSource || isDropTarget) ? App.Theme.accent : App.Theme.borderDefault
                            z: isSource ? 100 : 0
                            antialiasing: true

                            Behavior on scale {
                                NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
                            }
                            Behavior on border.width {
                                NumberAnimation { duration: 120 }
                            }
                            Behavior on color {
                                ColorAnimation { duration: 100 }
                            }

                            Image {
                                anchors.fill: parent
                                anchors.margins: 2
                                source: "file:///" + modelData
                                fillMode: Image.PreserveAspectCrop
                                smooth: true
                                asynchronous: true
                            }

                            // 左下角编号 badge "图 N"
                            Rectangle {
                                anchors.left: parent.left
                                anchors.bottom: parent.bottom
                                anchors.margins: 4
                                width: idxText.implicitWidth + 10
                                height: 18
                                radius: App.Theme.radiusSm
                                color: Qt.rgba(0, 0, 0, 0.6)
                                z: 2

                                Text {
                                    id: idxText
                                    anchors.centerIn: parent
                                    text: "图 " + (index + 1)
                                    color: "white"
                                    font.family: App.Theme.fontFamily
                                    font.pointSize: App.Theme.fontXs
                                    font.weight: Font.DemiBold
                                }
                            }

                            // 用 DragHandler 替代 MouseArea:active 一旦激活就 grab 全局指针,
                            // 即使鼠标拖出 delegate / ListView / 主窗口范围,释放事件也保证收到,
                            // 不会出现 ghost 卡住 / 状态泄漏的情况。
                            HoverHandler {
                                id: hoverHandler
                                cursorShape: dragHandler.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor
                            }
                            DragHandler {
                                id: dragHandler
                                target: null   // 自己不移动 delegate;ghost 单独绘制
                                property bool moveBeyondThreshold: false

                                onActiveChanged: {
                                    if (active) {
                                        tab.draggingIndex = index
                                        tab.dropTargetIndex = -1
                                        moveBeyondThreshold = false
                                        var p = mapToItem(dropZone, centroid.position.x, centroid.position.y)
                                        tab.dragGhostX = p.x
                                        tab.dragGhostY = p.y
                                    } else {
                                        // 释放:先把 reorder 决策快照下来,再立刻清状态,最后才 reorder。
                                        // 关键顺序:reorder 改 model → ListView 重建 delegate → DragHandler 销毁,
                                        // 后续代码可能不执行。所以必须先清状态。
                                        var shouldReorder = moveBeyondThreshold
                                            && tab.draggingIndex >= 0
                                            && tab.dropTargetIndex >= 0
                                            && tab.dropTargetIndex !== tab.draggingIndex
                                        var fromIdx = tab.draggingIndex
                                        var toIdx = tab.dropTargetIndex

                                        moveBeyondThreshold = false
                                        tab.draggingIndex = -1
                                        tab.dropTargetIndex = -1
                                        tab.dragGhostX = -1
                                        tab.dragGhostY = -1

                                        if (shouldReorder) {
                                            tab.reorderRefAt(fromIdx, toIdx)
                                        }
                                    }
                                }
                                onCentroidChanged: {
                                    if (!active) return
                                    // ghost 跟手
                                    var p = mapToItem(dropZone, centroid.position.x, centroid.position.y)
                                    tab.dragGhostX = p.x
                                    tab.dragGhostY = p.y

                                    // 8px 阈值防误触
                                    if (!moveBeyondThreshold) {
                                        var dx = centroid.position.x - centroid.pressPosition.x
                                        if (Math.abs(dx) <= 8) return
                                        moveBeyondThreshold = true
                                    }

                                    // 找鼠标 hover 的 delegate 作为 drop target
                                    var pt = mapToItem(refList.contentItem, centroid.position.x, centroid.position.y)
                                    // y 用 list 中线避免鼠标 y 越界(横向 list 行高 96,鼠标拖到上下空白时
                                    // indexAt 会返回 -1,但 x 是有效的)
                                    var t = refList.indexAt(pt.x, refList.height / 2)
                                    if (t < 0 && tab.refImages.length > 0) {
                                        // x 超出最后一个 delegate 右边界 → drop 到末尾
                                        if (pt.x >= refList.contentWidth) {
                                            t = tab.refImages.length - 1
                                        } else if (pt.x < 0) {
                                            t = 0
                                        }
                                    }
                                    tab.dropTargetIndex = t
                                }
                            }

                            // 右上角删除按钮(z 最高,不被拖拽 MouseArea 拦截)
                            Rectangle {
                                width: 20; height: 20; radius: 10
                                color: App.Theme.danger
                                anchors.top: parent.top
                                anchors.right: parent.right
                                anchors.margins: 2
                                z: 3

                                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)
                                }
                            }
                        }
                    }

                    // 外部文件拖入(与内部缩略图重排是两套,靠 keys 区分)
                    DropArea {
                        id: dropArea
                        anchors.fill: parent
                        keys: ["text/uri-list"]
                        onDropped: function(drop) {
                            if (drop.urls && drop.urls.length > 0) {
                                tab.addUrlsValidated(drop.urls, "拖入图片")
                                drop.acceptProposedAction()
                            }
                        }
                    }

                    // 拖拽 ghost — 拖拽时跟随鼠标的浮层,让"被拖动的图片"明确跟手
                    Rectangle {
                        id: dragGhost
                        width: 96
                        height: 96
                        radius: App.Theme.radiusSm
                        color: App.Theme.bgSurface
                        border.width: 2
                        border.color: App.Theme.accent
                        visible: tab.draggingIndex >= 0 && tab.dragGhostX >= 0
                        x: tab.dragGhostX - 48
                        y: tab.dragGhostY - 48
                        z: 200
                        opacity: 0.9
                        scale: 1.08
                        antialiasing: true

                        Image {
                            anchors.fill: parent
                            anchors.margins: 2
                            source: (tab.draggingIndex >= 0 && tab.draggingIndex < tab.refImages.length)
                                ? "file:///" + tab.refImages[tab.draggingIndex]
                                : ""
                            fillMode: Image.PreserveAspectCrop
                            smooth: true
                            asynchronous: true
                        }
                    }
                }
            }
        }

        // ===== 提示词 + 生成设置(并排)=====
        RowLayout {
            spacing: App.Theme.space4
            Layout.fillWidth: true
            Layout.preferredHeight: 290
            Layout.minimumHeight: 280

            // 提示词
            Card {
                Layout.fillWidth: true
                Layout.preferredWidth: 600
                Layout.fillHeight: true

                ColumnLayout {
                    anchors.fill: parent
                    anchors.margins: App.Theme.space4
                    spacing: App.Theme.space3

                    CaptionLabel {
                        text: "提示词"
                        font.pointSize: App.Theme.fontLg
                    }

                    RowLayout {
                        spacing: App.Theme.space2
                        SecondaryButton {
                            // isFavorited binding 依赖 promptArea.text + imageGen.savedPrompts,
                            // 任一变化都会重新评估
                            readonly property bool isFavorited: {
                                var p = promptArea.text.trim()
                                return p.length > 0 && imageGen.savedPrompts.indexOf(p) >= 0
                            }
                            text: isFavorited ? "✓ 已收藏" : "⭐ 收藏"
                            enabled: promptArea.text.trim().length > 0
                            onClicked: {
                                var nowFav = imageGen.toggleSavedPrompt(promptArea.text.trim())
                                tab.statusText = nowFav ? "● 已收藏" : "● 已取消收藏"
                                tab.statusColor = App.Theme.success
                            }
                        }
                        Label {
                            text: "快速选择:"
                            font.family: App.Theme.fontFamily
                            font.pointSize: App.Theme.fontSm
                            color: App.Theme.textSecondary
                        }
                        ThemedComboBox {
                            id: quickPromptCombo
                            Layout.fillWidth: true
                            model: imageGen.savedPrompts
                            onActivated: if (currentText) promptArea.text = currentText
                        }
                        SecondaryButton {
                            text: "删除"
                            enabled: imageGen.savedPrompts.length > 0
                            onClicked: {
                                if (quickPromptCombo.currentText) {
                                    imageGen.removeSavedPrompt(quickPromptCombo.currentText)
                                    tab.statusText = "● 已删除提示词"
                                    tab.statusColor = App.Theme.success
                                }
                            }
                        }
                    }

                    ScrollView {
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                        TextArea {
                            id: promptArea
                            text: ""
                            placeholderText: "描述你想生成的图片…"
                            font.family: App.Theme.fontFamily
                            font.pointSize: App.Theme.fontBase
                            color: App.Theme.textPrimary
                            wrapMode: TextArea.Wrap
                            selectionColor: App.Theme.accent
                            selectedTextColor: App.Theme.textOnAccent
                            background: Rectangle {
                                color: App.Theme.bgSubtle
                                radius: App.Theme.radiusMd
                                border.width: 1
                                border.color: App.Theme.borderDefault
                            }

                            // 右键编辑菜单
                            Menu {
                                id: promptEditMenu
                                MenuItem {
                                    text: "剪切"
                                    enabled: promptArea.selectedText.length > 0
                                    onTriggered: promptArea.cut()
                                }
                                MenuItem {
                                    text: "复制"
                                    enabled: promptArea.selectedText.length > 0
                                    onTriggered: promptArea.copy()
                                }
                                MenuItem {
                                    text: "粘贴"
                                    enabled: promptArea.canPaste
                                    onTriggered: promptArea.paste()
                                }
                                MenuSeparator {}
                                MenuItem {
                                    text: "全选"
                                    enabled: promptArea.length > 0
                                    onTriggered: promptArea.selectAll()
                                }
                            }

                            TapHandler {
                                acceptedButtons: Qt.RightButton
                                gesturePolicy: TapHandler.WithinBounds
                                onTapped: promptEditMenu.popup()
                            }
                        }
                    }
                }
            }

            // 生成设置
            Card {
                Layout.preferredWidth: 280
                Layout.fillHeight: true

                ColumnLayout {
                    anchors.fill: parent
                    anchors.margins: App.Theme.space4
                    spacing: App.Theme.space3

                    CaptionLabel {
                        text: "生成设置"
                        font.pointSize: App.Theme.fontLg
                    }

                    ColumnLayout {
                        spacing: 4
                        Layout.fillWidth: true
                        CaptionLabel { text: "生成模式"; font.pointSize: App.Theme.fontBase }
                        ThemedComboBox {
                            id: modeCombo
                            Layout.fillWidth: true
                            model: ["极速模式", "慢速模式"]
                            onActivated: {
                                // 切到慢速但当前比例仅极速支持 → 自动回退到 1:1 + 提示
                                if (currentText === "慢速模式" && tab.isFlashOnly(aspectCombo.currentText)) {
                                    var oldRatio = aspectCombo.currentText
                                    aspectCombo.currentIndex = 0  // 1:1
                                    tab.statusText = "● 已切到 1:1(慢速模式不支持 " + oldRatio + ")"
                                    tab.statusColor = App.Theme.warning
                                }
                            }
                        }
                    }

                    ColumnLayout {
                        spacing: 4
                        Layout.fillWidth: true
                        CaptionLabel { text: "宽高比"; font.pointSize: App.Theme.fontBase }
                        ThemedComboBox {
                            id: aspectCombo
                            Layout.fillWidth: true
                            // 与 core/generation.py 一致:14 个比例(前 10 通用,后 4 仅极速)
                            model: ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4",
                                    "9:16", "16:9", "21:9",
                                    "1:4", "4:1", "1:8", "8:1"]
                            onActivated: {
                                // 选了极速独占比例但当前是慢速 → 自动切到极速 + 提示
                                if (tab.isFlashOnly(currentText) && modeCombo.currentText !== "极速模式") {
                                    modeCombo.currentIndex = 0
                                    tab.statusText = "● 已自动切到极速模式(" + currentText + " 仅极速支持)"
                                    tab.statusColor = App.Theme.warning
                                }
                            }
                        }
                    }

                    ColumnLayout {
                        spacing: 4
                        Layout.fillWidth: true
                        CaptionLabel { text: "图片尺寸"; font.pointSize: App.Theme.fontBase }
                        ThemedComboBox {
                            id: sizeCombo
                            Layout.fillWidth: true
                            model: ["1K", "2K", "4K"]
                        }
                    }

                    Item { Layout.fillHeight: true }
                }
            }
        }

        // ===== 操作按钮行 =====
        RowLayout {
            spacing: App.Theme.space3
            Layout.fillWidth: true

            PrimaryButton {
                text: "生成图片"
                onClicked: tab.submit()
            }
            SecondaryButton {
                text: "下载图片"
                enabled: tab.lastResultPath !== ""
                onClicked: saveImageDialog.open()
            }
            Label {
                text: tab.statusText
                font.family: App.Theme.fontFamily
                font.pointSize: App.Theme.fontSm
                color: tab.statusColor
            }
            Item { Layout.fillWidth: true }
        }

        // ===== 预览卡片 =====
        Card {
            Layout.fillWidth: true
            Layout.fillHeight: true
            Layout.minimumHeight: 240

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: App.Theme.space4
                spacing: App.Theme.space3

                CaptionLabel {
                    text: "预览"
                    font.pointSize: App.Theme.fontLg
                }

                Rectangle {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    color: App.Theme.bgSubtle
                    radius: App.Theme.radiusMd

                    Image {
                        id: previewImage
                        anchors.fill: parent
                        anchors.margins: App.Theme.space3
                        source: tab.lastResultPath ? "file:///" + tab.lastResultPath : ""
                        fillMode: Image.PreserveAspectFit
                        smooth: true
                        asynchronous: true
                        cache: false  // 同路径下次生成会被覆盖,强制重读
                        visible: tab.lastResultPath !== ""

                        MouseArea {
                            anchors.fill: parent
                            cursorShape: Qt.PointingHandCursor
                            onDoubleClicked: {
                                if (tab.lastResultPath) {
                                    Qt.openUrlExternally("file:///" + tab.lastResultPath)
                                }
                            }
                        }
                    }

                    Column {
                        anchors.centerIn: parent
                        spacing: 4
                        visible: tab.lastResultPath === ""

                        Text {
                            anchors.horizontalCenter: parent.horizontalCenter
                            text: "生成的图片将在这里显示"
                            color: App.Theme.textTertiary
                            font.family: App.Theme.fontFamily
                            font.pointSize: App.Theme.fontSm
                        }
                        Text {
                            anchors.horizontalCenter: parent.horizontalCenter
                            text: "双击用系统查看器打开"
                            color: App.Theme.textTertiary
                            font.family: App.Theme.fontFamily
                            font.pointSize: App.Theme.fontXs
                        }
                    }
                }
            }
        }
    }
}