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): ...@@ -83,3 +83,15 @@ class HistoryBridge(QObject):
83 "model": item.model, 83 "model": item.model,
84 "createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"), 84 "createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"),
85 } 85 }
86
87 @Slot(str, result=str)
88 def thumbnailPath(self, timestamp: str) -> str:
89 """返回该 timestamp 缩略图本地路径(按需生成)。源图缺失时返回 ""。"""
90 item = self._history.load_history_item_fast(timestamp)
91 if item is None or not item.generated_image_path.exists():
92 return ""
93 thumb = self._history.get_or_create_thumbnail(item.generated_image_path)
94 if thumb is None:
95 # 缩略图生成失败时回退到原图(QML Image 读 PNG 没问题)
96 return str(item.generated_image_path)
97 return str(thumb)
......
...@@ -85,6 +85,19 @@ class HistoryListModel(QAbstractListModel): ...@@ -85,6 +85,19 @@ class HistoryListModel(QAbstractListModel):
85 return 0 85 return 0
86 return len(self._timestamps) 86 return len(self._timestamps)
87 87
88 def roleNames(self):
89 """暴露 Qt 内置 roles 给 QML(默认 QML 只能 model.display)。
90
91 QListView (旧 QWidget UI) 用 int role 不受影响;QML ListView delegate
92 现在能用 model.timestamp / model.toolTip 等访问。
93 """
94 return {
95 Qt.DisplayRole: b"display",
96 Qt.DecorationRole: b"decoration",
97 Qt.ToolTipRole: b"toolTip",
98 Qt.UserRole: b"timestamp",
99 }
100
88 def flags(self, index: QModelIndex): 101 def flags(self, index: QModelIndex):
89 if not index.isValid(): 102 if not index.isValid():
90 return Qt.NoItemFlags 103 return Qt.NoItemFlags
......
...@@ -54,7 +54,11 @@ class AppState(QObject): ...@@ -54,7 +54,11 @@ class AppState(QObject):
54 self._auth = auth_bridge 54 self._auth = auth_bridge
55 # 兼容现有 QML:env QML_AUTO_LOGIN=1 时强制 loggedIn=True 55 # 兼容现有 QML:env QML_AUTO_LOGIN=1 时强制 loggedIn=True
56 self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1" 56 self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1"
57 self._current_tab = 0 57 # 调试便捷:env QML_DEBUG_TAB=0/1/2 控制启动时默认 tab
58 try:
59 self._current_tab = int(os.environ.get("QML_DEBUG_TAB", "0"))
60 except ValueError:
61 self._current_tab = 0
58 self._auth.loggedInChanged.connect(self.loggedInChanged.emit) 62 self._auth.loggedInChanged.connect(self.loggedInChanged.emit)
59 63
60 @Property(bool, notify=loggedInChanged) 64 @Property(bool, notify=loggedInChanged)
......
1 import QtQuick
2 import QtQuick.Controls.Basic
3 import QtQuick.Layouts
4 import "components"
5 import "." as App
6
7 Item {
8 id: tab
9
10 property string selectedTimestamp: ""
11 property var selectedItem: ({})
12
13 function selectTimestamp(ts) {
14 if (!ts) {
15 tab.selectedTimestamp = ""
16 tab.selectedItem = ({})
17 return
18 }
19 tab.selectedTimestamp = ts
20 tab.selectedItem = history.getItem(ts) || ({})
21 }
22
23 // 列表数据变化时校正选中项
24 Connections {
25 target: history
26 function onItemRemoved(ts) {
27 if (tab.selectedTimestamp === ts) tab.selectTimestamp("")
28 }
29 function onCountChanged() {
30 // 自动选中第一条(首次加载 / 新增时)
31 if (tab.selectedTimestamp === "" && historyList.count > 0) {
32 var idx = historyList.model.index(0, 0)
33 var ts = historyList.model.data(idx, Qt.UserRole)
34 if (ts) tab.selectTimestamp(ts)
35 }
36 }
37 }
38
39 Component.onCompleted: {
40 if (historyList.count > 0) {
41 var idx = historyList.model.index(0, 0)
42 var ts = historyList.model.data(idx, Qt.UserRole)
43 if (ts) tab.selectTimestamp(ts)
44 }
45 }
46
47 RowLayout {
48 anchors.fill: parent
49 anchors.margins: App.Theme.space5
50 spacing: App.Theme.space4
51
52 // ===== 左侧:列表 =====
53 Card {
54 Layout.preferredWidth: 340
55 Layout.fillHeight: true
56
57 ColumnLayout {
58 anchors.fill: parent
59 anchors.margins: App.Theme.space4
60 spacing: App.Theme.space3
61
62 RowLayout {
63 spacing: App.Theme.space3
64 CaptionLabel {
65 text: "历史记录"
66 font.pointSize: App.Theme.fontLg
67 }
68 Label {
69 text: history.count + " 条"
70 font.family: App.Theme.fontFamily
71 font.pointSize: App.Theme.fontSm
72 color: App.Theme.textSecondary
73 }
74 Item { Layout.fillWidth: true }
75 SecondaryButton {
76 text: "刷新"
77 onClicked: history.refresh()
78 }
79 }
80
81 // 空态
82 Label {
83 visible: historyList.count === 0
84 Layout.fillWidth: true
85 Layout.fillHeight: true
86 horizontalAlignment: Text.AlignHCenter
87 verticalAlignment: Text.AlignVCenter
88 text: "暂无历史记录\n生成图片后会自动出现在这里"
89 font.family: App.Theme.fontFamily
90 font.pointSize: App.Theme.fontSm
91 color: App.Theme.textTertiary
92 }
93
94 ListView {
95 id: historyList
96 Layout.fillWidth: true
97 Layout.fillHeight: true
98 visible: count > 0
99 spacing: 4
100 clip: true
101 model: history.model
102
103 delegate: Rectangle {
104 required property int index
105 required property string display
106 required property string toolTip
107 required property string timestamp
108
109 width: ListView.view.width
110 height: 76
111 radius: App.Theme.radiusSm
112 color: tab.selectedTimestamp === timestamp ? App.Theme.accentSubtle
113 : itemMouse.containsMouse ? App.Theme.bgHover
114 : "transparent"
115 border.width: tab.selectedTimestamp === timestamp ? 1 : 0
116 border.color: App.Theme.accent
117
118 RowLayout {
119 anchors.fill: parent
120 anchors.margins: App.Theme.space2
121 spacing: App.Theme.space3
122
123 // 缩略图
124 Rectangle {
125 Layout.preferredWidth: 60
126 Layout.preferredHeight: 60
127 radius: App.Theme.radiusSm
128 color: App.Theme.bgSubtle
129
130 Image {
131 id: thumb
132 anchors.fill: parent
133 anchors.margins: 1
134 source: {
135 var p = history.thumbnailPath(timestamp)
136 return p ? "file:///" + p : ""
137 }
138 fillMode: Image.PreserveAspectCrop
139 smooth: true
140 asynchronous: true
141 }
142 }
143
144 // 文字
145 ColumnLayout {
146 Layout.fillWidth: true
147 Layout.fillHeight: true
148 spacing: 2
149
150 Text {
151 Layout.fillWidth: true
152 text: timestamp
153 color: App.Theme.textPrimary
154 font.family: App.Theme.fontFamily
155 font.pointSize: App.Theme.fontXs
156 font.weight: Font.DemiBold
157 }
158 Text {
159 Layout.fillWidth: true
160 text: display.split("\n").slice(1).join(" ")
161 color: App.Theme.textSecondary
162 font.family: App.Theme.fontFamily
163 font.pointSize: App.Theme.fontXs
164 elide: Text.ElideRight
165 maximumLineCount: 2
166 wrapMode: Text.Wrap
167 }
168 }
169 }
170
171 MouseArea {
172 id: itemMouse
173 anchors.fill: parent
174 hoverEnabled: true
175 cursorShape: Qt.PointingHandCursor
176 onClicked: tab.selectTimestamp(timestamp)
177 }
178
179 ToolTip.text: toolTip
180 ToolTip.visible: itemMouse.containsMouse && toolTip.length > 0
181 ToolTip.delay: 600
182 }
183 }
184 }
185 }
186
187 // ===== 右侧:详情 =====
188 Card {
189 Layout.fillWidth: true
190 Layout.fillHeight: true
191
192 // 空态
193 Label {
194 anchors.centerIn: parent
195 visible: tab.selectedTimestamp === ""
196 text: "选择左侧记录查看详情"
197 font.family: App.Theme.fontFamily
198 font.pointSize: App.Theme.fontSm
199 color: App.Theme.textTertiary
200 }
201
202 ColumnLayout {
203 visible: tab.selectedTimestamp !== ""
204 anchors.fill: parent
205 anchors.margins: App.Theme.space4
206 spacing: App.Theme.space3
207
208 // header: 时间 + 操作按钮
209 RowLayout {
210 Layout.fillWidth: true
211 spacing: App.Theme.space3
212
213 CaptionLabel {
214 text: tab.selectedItem.timestamp || ""
215 font.pointSize: App.Theme.fontLg
216 }
217 Label {
218 text: tab.selectedItem.createdAt || ""
219 font.family: App.Theme.fontFamily
220 font.pointSize: App.Theme.fontSm
221 color: App.Theme.textSecondary
222 }
223 Item { Layout.fillWidth: true }
224 SecondaryButton {
225 text: "在文件管理器中打开"
226 onClicked: {
227 if (tab.selectedItem.generatedImagePath) {
228 Qt.openUrlExternally("file:///" + tab.selectedItem.generatedImagePath)
229 }
230 }
231 }
232 SecondaryButton {
233 text: "删除"
234 onClicked: {
235 if (tab.selectedTimestamp) {
236 history.deleteItem(tab.selectedTimestamp)
237 }
238 }
239 }
240 }
241
242 // 大图
243 Rectangle {
244 Layout.fillWidth: true
245 Layout.preferredHeight: 360
246 color: App.Theme.bgSubtle
247 radius: App.Theme.radiusMd
248
249 Image {
250 anchors.fill: parent
251 anchors.margins: App.Theme.space3
252 source: tab.selectedItem.generatedImagePath
253 ? "file:///" + tab.selectedItem.generatedImagePath
254 : ""
255 fillMode: Image.PreserveAspectFit
256 smooth: true
257 asynchronous: true
258
259 MouseArea {
260 anchors.fill: parent
261 cursorShape: Qt.PointingHandCursor
262 onDoubleClicked: {
263 if (tab.selectedItem.generatedImagePath) {
264 Qt.openUrlExternally("file:///" + tab.selectedItem.generatedImagePath)
265 }
266 }
267 }
268 }
269 }
270
271 // 元信息
272 GridLayout {
273 Layout.fillWidth: true
274 columns: 4
275 columnSpacing: App.Theme.space4
276 rowSpacing: App.Theme.space2
277
278 CaptionLabel { text: "宽高比" }
279 Label {
280 text: tab.selectedItem.aspectRatio || "—"
281 font.family: App.Theme.fontFamily
282 font.pointSize: App.Theme.fontSm
283 color: App.Theme.textPrimary
284 }
285 CaptionLabel { text: "尺寸" }
286 Label {
287 text: tab.selectedItem.imageSize || "—"
288 font.family: App.Theme.fontFamily
289 font.pointSize: App.Theme.fontSm
290 color: App.Theme.textPrimary
291 }
292
293 CaptionLabel { text: "模型" }
294 Label {
295 Layout.columnSpan: 3
296 text: tab.selectedItem.model || "—"
297 font.family: App.Theme.fontFamily
298 font.pointSize: App.Theme.fontSm
299 color: App.Theme.textPrimary
300 elide: Text.ElideRight
301 Layout.fillWidth: true
302 }
303 }
304
305 // prompt
306 CaptionLabel { text: "提示词" }
307 ScrollView {
308 Layout.fillWidth: true
309 Layout.preferredHeight: 80
310 Rectangle {
311 anchors.fill: parent
312 color: App.Theme.bgSubtle
313 radius: App.Theme.radiusMd
314 Text {
315 anchors.fill: parent
316 anchors.margins: App.Theme.space3
317 text: tab.selectedItem.prompt || ""
318 color: App.Theme.textPrimary
319 font.family: App.Theme.fontFamily
320 font.pointSize: App.Theme.fontSm
321 wrapMode: Text.Wrap
322 }
323 }
324 }
325
326 Item { Layout.fillHeight: true }
327 }
328 }
329 }
330 }
...@@ -107,17 +107,7 @@ Rectangle { ...@@ -107,17 +107,7 @@ Rectangle {
107 } 107 }
108 } 108 }
109 109
110 // 历史记录 — 占位 110 HistoryTab {}
111 Item {
112 Text {
113 anchors.centerIn: parent
114 text: "历史记录 tab 内容\n(QML PoC 暂未实现)"
115 color: App.Theme.textTertiary
116 font.family: App.Theme.fontFamily
117 font.pointSize: App.Theme.fontLg
118 horizontalAlignment: Text.AlignHCenter
119 }
120 }
121 } 111 }
122 } 112 }
123 113
......