cabdf6a1 by 柴进

feat(qml): task #16 历史记录 tab 完整重做(ListView + 详情面板)

core/history.py:
  HistoryListModel.roleNames() 暴露 4 个 Qt 内置 role 给 QML:
  display / decoration / toolTip / timestamp (UserRole)
  旧 QListView 用 int role 不受影响

bridges/history.py:
  Slot thumbnailPath(timestamp) → str
  返回 thumb.jpg 本地路径(按需用 PIL 生成 240px JPEG),缩略图缺失回退原图

qml_poc/qml/HistoryTab.qml (新):
  - 左侧 340px 卡片:列表 (history.model)
    * delegate 76px 高:60×60 缩略图 + timestamp + prompt 双行摘要
    * 选中态 accent 蓝边框 + accentSubtle 浅蓝底
    * hover 提示完整 toolTip
  - 右侧详情卡片:
    * timestamp + createdAt + "在文件管理器中打开" + "删除"
    * 360px 大图(双击系统查看器打开)
    * GridLayout 4 列元信息(宽高比/尺寸/模型)
    * prompt ScrollView(长文本可滚)
  - Component.onCompleted + onCountChanged 自动选中第一条
  - itemRemoved 信号 → 选中失效时回到空态

qml_poc/main_qml.py:
  AppState 加 QML_DEBUG_TAB env var (0/1/2) 控制启动 tab 索引,方便 PoC 调试

MainWindow.qml: 历史记录占位 Item → HistoryTab {}

视觉验证:QML_DEBUG_TAB=2 直接进历史 tab,2 条已存历史正确渲染(缩略图 +
详情大图 + 元信息 + prompt 都对),UI 无回归。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7cf6c18e
......@@ -83,3 +83,15 @@ class HistoryBridge(QObject):
"model": item.model,
"createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
@Slot(str, result=str)
def thumbnailPath(self, timestamp: str) -> str:
"""返回该 timestamp 缩略图本地路径(按需生成)。源图缺失时返回 ""。"""
item = self._history.load_history_item_fast(timestamp)
if item is None or not item.generated_image_path.exists():
return ""
thumb = self._history.get_or_create_thumbnail(item.generated_image_path)
if thumb is None:
# 缩略图生成失败时回退到原图(QML Image 读 PNG 没问题)
return str(item.generated_image_path)
return str(thumb)
......
......@@ -85,6 +85,19 @@ class HistoryListModel(QAbstractListModel):
return 0
return len(self._timestamps)
def roleNames(self):
"""暴露 Qt 内置 roles 给 QML(默认 QML 只能 model.display)。
QListView (旧 QWidget UI) 用 int role 不受影响;QML ListView delegate
现在能用 model.timestamp / model.toolTip 等访问。
"""
return {
Qt.DisplayRole: b"display",
Qt.DecorationRole: b"decoration",
Qt.ToolTipRole: b"toolTip",
Qt.UserRole: b"timestamp",
}
def flags(self, index: QModelIndex):
if not index.isValid():
return Qt.NoItemFlags
......
......@@ -54,6 +54,10 @@ class AppState(QObject):
self._auth = auth_bridge
# 兼容现有 QML:env QML_AUTO_LOGIN=1 时强制 loggedIn=True
self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1"
# 调试便捷:env QML_DEBUG_TAB=0/1/2 控制启动时默认 tab
try:
self._current_tab = int(os.environ.get("QML_DEBUG_TAB", "0"))
except ValueError:
self._current_tab = 0
self._auth.loggedInChanged.connect(self.loggedInChanged.emit)
......
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import "components"
import "." as App
Item {
id: tab
property string selectedTimestamp: ""
property var selectedItem: ({})
function selectTimestamp(ts) {
if (!ts) {
tab.selectedTimestamp = ""
tab.selectedItem = ({})
return
}
tab.selectedTimestamp = ts
tab.selectedItem = history.getItem(ts) || ({})
}
// 列表数据变化时校正选中项
Connections {
target: history
function onItemRemoved(ts) {
if (tab.selectedTimestamp === ts) tab.selectTimestamp("")
}
function onCountChanged() {
// 自动选中第一条(首次加载 / 新增时)
if (tab.selectedTimestamp === "" && historyList.count > 0) {
var idx = historyList.model.index(0, 0)
var ts = historyList.model.data(idx, Qt.UserRole)
if (ts) tab.selectTimestamp(ts)
}
}
}
Component.onCompleted: {
if (historyList.count > 0) {
var idx = historyList.model.index(0, 0)
var ts = historyList.model.data(idx, Qt.UserRole)
if (ts) tab.selectTimestamp(ts)
}
}
RowLayout {
anchors.fill: parent
anchors.margins: App.Theme.space5
spacing: App.Theme.space4
// ===== 左侧:列表 =====
Card {
Layout.preferredWidth: 340
Layout.fillHeight: true
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
}
Label {
text: history.count + " 条"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textSecondary
}
Item { Layout.fillWidth: true }
SecondaryButton {
text: "刷新"
onClicked: history.refresh()
}
}
// 空态
Label {
visible: historyList.count === 0
Layout.fillWidth: true
Layout.fillHeight: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: "暂无历史记录\n生成图片后会自动出现在这里"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textTertiary
}
ListView {
id: historyList
Layout.fillWidth: true
Layout.fillHeight: true
visible: count > 0
spacing: 4
clip: true
model: history.model
delegate: Rectangle {
required property int index
required property string display
required property string toolTip
required property string timestamp
width: ListView.view.width
height: 76
radius: App.Theme.radiusSm
color: tab.selectedTimestamp === timestamp ? App.Theme.accentSubtle
: itemMouse.containsMouse ? App.Theme.bgHover
: "transparent"
border.width: tab.selectedTimestamp === timestamp ? 1 : 0
border.color: App.Theme.accent
RowLayout {
anchors.fill: parent
anchors.margins: App.Theme.space2
spacing: App.Theme.space3
// 缩略图
Rectangle {
Layout.preferredWidth: 60
Layout.preferredHeight: 60
radius: App.Theme.radiusSm
color: App.Theme.bgSubtle
Image {
id: thumb
anchors.fill: parent
anchors.margins: 1
source: {
var p = history.thumbnailPath(timestamp)
return p ? "file:///" + p : ""
}
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
}
}
// 文字
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 2
Text {
Layout.fillWidth: true
text: timestamp
color: App.Theme.textPrimary
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontXs
font.weight: Font.DemiBold
}
Text {
Layout.fillWidth: true
text: display.split("\n").slice(1).join(" ")
color: App.Theme.textSecondary
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontXs
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.Wrap
}
}
}
MouseArea {
id: itemMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: tab.selectTimestamp(timestamp)
}
ToolTip.text: toolTip
ToolTip.visible: itemMouse.containsMouse && toolTip.length > 0
ToolTip.delay: 600
}
}
}
}
// ===== 右侧:详情 =====
Card {
Layout.fillWidth: true
Layout.fillHeight: true
// 空态
Label {
anchors.centerIn: parent
visible: tab.selectedTimestamp === ""
text: "选择左侧记录查看详情"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textTertiary
}
ColumnLayout {
visible: tab.selectedTimestamp !== ""
anchors.fill: parent
anchors.margins: App.Theme.space4
spacing: App.Theme.space3
// header: 时间 + 操作按钮
RowLayout {
Layout.fillWidth: true
spacing: App.Theme.space3
CaptionLabel {
text: tab.selectedItem.timestamp || ""
font.pointSize: App.Theme.fontLg
}
Label {
text: tab.selectedItem.createdAt || ""
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textSecondary
}
Item { Layout.fillWidth: true }
SecondaryButton {
text: "在文件管理器中打开"
onClicked: {
if (tab.selectedItem.generatedImagePath) {
Qt.openUrlExternally("file:///" + tab.selectedItem.generatedImagePath)
}
}
}
SecondaryButton {
text: "删除"
onClicked: {
if (tab.selectedTimestamp) {
history.deleteItem(tab.selectedTimestamp)
}
}
}
}
// 大图
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 360
color: App.Theme.bgSubtle
radius: App.Theme.radiusMd
Image {
anchors.fill: parent
anchors.margins: App.Theme.space3
source: tab.selectedItem.generatedImagePath
? "file:///" + tab.selectedItem.generatedImagePath
: ""
fillMode: Image.PreserveAspectFit
smooth: true
asynchronous: true
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onDoubleClicked: {
if (tab.selectedItem.generatedImagePath) {
Qt.openUrlExternally("file:///" + tab.selectedItem.generatedImagePath)
}
}
}
}
}
// 元信息
GridLayout {
Layout.fillWidth: true
columns: 4
columnSpacing: App.Theme.space4
rowSpacing: App.Theme.space2
CaptionLabel { text: "宽高比" }
Label {
text: tab.selectedItem.aspectRatio || "—"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textPrimary
}
CaptionLabel { text: "尺寸" }
Label {
text: tab.selectedItem.imageSize || "—"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textPrimary
}
CaptionLabel { text: "模型" }
Label {
Layout.columnSpan: 3
text: tab.selectedItem.model || "—"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textPrimary
elide: Text.ElideRight
Layout.fillWidth: true
}
}
// prompt
CaptionLabel { text: "提示词" }
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: 80
Rectangle {
anchors.fill: parent
color: App.Theme.bgSubtle
radius: App.Theme.radiusMd
Text {
anchors.fill: parent
anchors.margins: App.Theme.space3
text: tab.selectedItem.prompt || ""
color: App.Theme.textPrimary
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
wrapMode: Text.Wrap
}
}
}
Item { Layout.fillHeight: true }
}
}
}
}
......@@ -107,17 +107,7 @@ Rectangle {
}
}
// 历史记录 — 占位
Item {
Text {
anchors.centerIn: parent
text: "历史记录 tab 内容\n(QML PoC 暂未实现)"
color: App.Theme.textTertiary
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontLg
horizontalAlignment: Text.AlignHCenter
}
}
HistoryTab {}
}
}
......