-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patharchive_widget.py
More file actions
296 lines (259 loc) · 16.1 KB
/
Copy patharchive_widget.py
File metadata and controls
296 lines (259 loc) · 16.1 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
"""v0.3 档案/分析/修复 UI。只消费已解析交易与 SQLite API。"""
from __future__ import annotations
import datetime
import os
from PyQt6.QtCore import Qt, QDate, pyqtSignal
from PyQt6.QtGui import QColor, QTextCharFormat
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTabWidget,
QTableWidget, QTableWidgetItem, QHeaderView, QDialog, QFormLayout,
QLineEdit, QDialogButtonBox, QFileDialog, QMessageBox, QCalendarWidget,
QSpinBox, QCheckBox, QPlainTextEdit,
QCompleter,
)
import data_portability
import diagnostics
import history_store
import plugin_registry
import offline_maps
import trip_analytics as analytics
import yearbook_export
GREEN = "#3CFF6A"; MUTED = "#1FB04A"; BG = "#03140A"; PANEL = "#0A2412"
ORANGE = "#FFB000"; RED = "#FF7A33"; BORDER = "#1f8f3a"
def _item(value, data=None):
it = QTableWidgetItem(str(value if value not in (None, "") else "—"))
if data is not None:
it.setData(Qt.ItemDataRole.UserRole, data)
return it
class _StationFixDialog(QDialog):
def __init__(self, record, suggestions=None, parent=None):
super().__init__(parent); self.setWindowTitle("修复站点/线路"); self.setMinimumWidth(440)
form = QFormLayout(self); self.edits = {}
for key, label in (("in_station", "进站"), ("out_station", "出站"),
("line", "线路"), ("in_company", "进站公司"),
("out_company", "出站公司"), ("city", "城市"), ("region", "地区")):
edit = QLineEdit(str(record.get(key, "") or "")); self.edits[key] = edit
if key in ("in_station", "out_station") and suggestions:
values = suggestions.get(key) or suggestions.get("all") or []
completer = QCompleter(values, edit)
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
completer.setFilterMode(Qt.MatchFlag.MatchContains); edit.setCompleter(completer)
if suggestions.get(key):
edit.setPlaceholderText("候选: " + " / ".join(suggestions[key][:4]))
form.addRow(label, edit)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save |
QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept); buttons.rejected.connect(self.reject)
form.addRow(buttons)
def values(self):
return {k: e.text().strip() for k, e in self.edits.items()}
class ArchivePage(QWidget):
data_changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent); self._txs = []; self._card_id = None; self._dirty = True
root = QVBoxLayout(self); root.setContentsMargins(10, 10, 10, 10); root.setSpacing(8)
head = QHBoxLayout()
self.summary = QLabel("V3 ARCHIVE"); self.summary.setStyleSheet(f"color:{GREEN};font-weight:bold")
head.addWidget(self.summary); head.addStretch()
refresh = QPushButton("刷新"); refresh.clicked.connect(self.refresh); head.addWidget(refresh)
root.addLayout(head)
self.tabs = QTabWidget(); root.addWidget(self.tabs, 1)
self._build_quality(); self._build_routes(); self._build_calendar(); self._build_archive(); self._build_diag()
self._dirty_tabs = set(range(self.tabs.count()))
self.tabs.currentChanged.connect(self._refresh_tab)
def _table(self, headers):
t = QTableWidget(0, len(headers)); t.setHorizontalHeaderLabels(headers)
t.setAlternatingRowColors(True); t.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
t.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
t.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
t.horizontalHeader().setStretchLastSection(True)
return t
def _build_quality(self):
page = QWidget(); lay = QVBoxLayout(page)
self.quality_label = QLabel(); lay.addWidget(self.quality_label)
bar = QHBoxLayout(); fix = QPushButton("修复选中记录"); fix.clicked.connect(self._fix_selected)
bar.addWidget(fix); bar.addStretch(); lay.addLayout(bar)
self.unresolved = self._table(["日期", "卡片", "进站", "出站", "线路/备注"])
self.unresolved.cellDoubleClicked.connect(lambda _r, _c: self._fix_selected()); lay.addWidget(self.unresolved)
self.tabs.addTab(page, "数据质量 / 修复")
def _build_routes(self):
page = QWidget(); lay = QVBoxLayout(page)
self.route_label = QLabel(); lay.addWidget(self.route_label)
self.routes = self._table(["分类", "区间", "线路", "次数", "工作日率", "首次", "最近"])
lay.addWidget(self.routes, 2)
self.completion = self._table(["线路", "已到访", "总站数", "完成度"])
lay.addWidget(self.completion, 1); self.tabs.addTab(page, "频次 / 网络完成度")
def _build_calendar(self):
page = QWidget(); lay = QVBoxLayout(page); top = QHBoxLayout()
top.addWidget(QLabel("年份")); self.year = QSpinBox(); self.year.setRange(2000, 2100)
self.year.setValue(datetime.date.today().year); self.year.valueChanged.connect(self._refresh_calendar)
top.addWidget(self.year); top.addStretch(); self.calendar_info = QLabel(); top.addWidget(self.calendar_info)
lay.addLayout(top); self.calendar = QCalendarWidget(); lay.addWidget(self.calendar)
self._calendar_dates = []; self.tabs.addTab(page, "交通日历")
def _build_archive(self):
page = QWidget(); lay = QVBoxLayout(page); bar = QHBoxLayout()
backup = QPushButton("创建备份"); backup.clicked.connect(self._backup); bar.addWidget(backup)
restore = QPushButton("恢复备份"); restore.clicked.connect(self._restore); bar.addWidget(restore)
undo = QPushButton("撤销最近读取"); undo.clicked.connect(self._undo_scan); bar.addWidget(undo)
export_json = QPushButton("导出 JSON"); export_json.clicked.connect(self._export_json); bar.addWidget(export_json)
yearbook = QPushButton("导出交通年鉴"); yearbook.clicked.connect(self._yearbook); bar.addWidget(yearbook)
self.encrypt = QCheckBox("DPAPI 加密"); self.encrypt.setChecked(True); bar.addWidget(self.encrypt); bar.addStretch()
lay.addLayout(bar); self.archive_label = QLabel(); lay.addWidget(self.archive_label)
self.scans = self._table(["读取时间", "卡片", "读到", "新增", "解析器"]); lay.addWidget(self.scans)
self.tabs.addTab(page, "快照 / 备份 / 年鉴")
def _build_diag(self):
page = QWidget(); lay = QVBoxLayout(page); bar = QHBoxLayout()
btn = QPushButton("安全诊断(不访问读卡器)"); btn.clicked.connect(self._refresh_diag); bar.addWidget(btn)
plug = QPushButton("重新加载插件"); plug.clicked.connect(lambda: self._refresh_plugins(True)); bar.addWidget(plug)
ep = QPushButton("导出离线地图包"); ep.clicked.connect(self._export_map_pack); bar.addWidget(ep)
ip = QPushButton("导入离线地图包"); ip.clicked.connect(self._import_map_pack); bar.addWidget(ip)
bar.addStretch(); lay.addLayout(bar)
self.diag = QPlainTextEdit(); self.diag.setReadOnly(True); lay.addWidget(self.diag)
self.tabs.addTab(page, "诊断 / 插件")
def set_data(self, transactions, card_id=None):
self._txs = list(transactions); self._card_id = card_id; self._dirty = True
self._dirty_tabs = set(range(self.tabs.count()))
quality = analytics.data_quality(self._txs)
self.summary.setText(
f"V3 ARCHIVE · {len(self._txs)} 条 · {quality['mapped']} 条可绘图 · "
f"{quality['unresolved']} 条待修复")
def showEvent(self, event):
super().showEvent(event)
if self._dirty:
self.refresh()
def refresh(self):
self._refresh_tab(self.tabs.currentIndex(), force=True)
def _refresh_tab(self, index, force=False):
if index < 0 or (not force and index not in self._dirty_tabs):
return
refreshers = (self._refresh_quality, self._refresh_routes, self._refresh_calendar,
self._refresh_archive, self._refresh_diag)
if index < len(refreshers):
refreshers[index]()
self._dirty_tabs.discard(index)
self._dirty = bool(self._dirty_tabs)
def _refresh_quality(self):
q = analytics.data_quality(self._txs)
self.quality_label.setText(
f"总记录 {q['total']} · 可定位 {q['mapped']} · 日期有效 {q['dated']} · "
f"地图覆盖率 {q['map_rate']*100:.1f}%")
rows = history_store.list_unresolved(card_id=self._card_id); self.unresolved.setRowCount(0)
for entry in rows:
rec = entry["record"]; r = self.unresolved.rowCount(); self.unresolved.insertRow(r)
data = entry
vals = [rec.get("date_str", ""), entry["card_id"], rec.get("in_station", ""),
rec.get("out_station", ""), rec.get("line") or rec.get("memo", "")]
for c, value in enumerate(vals): self.unresolved.setItem(r, c, _item(value, data if c == 0 else None))
def _fix_selected(self):
row = self.unresolved.currentRow()
if row < 0 or not self.unresolved.item(row, 0):
QMessageBox.information(self, "修复", "请先选择一条待修复记录"); return
entry = self.unresolved.item(row, 0).data(Qt.ItemDataRole.UserRole)
suggestions = analytics.station_suggestions(entry["record"], self._txs)
dlg = _StationFixDialog(entry["record"], suggestions, self)
if dlg.exec() == QDialog.DialogCode.Accepted:
if history_store.apply_station_fix(entry["card_id"], entry["txn_key"], **dlg.values()):
self.data_changed.emit(); self.refresh()
def _refresh_routes(self):
classes = analytics.classify_routes(self._txs); self.routes.setRowCount(0)
for route in classes:
r = self.routes.rowCount(); self.routes.insertRow(r)
vals = [route["category"], f"{route['in_station']} → {route['out_station']}",
route["line"], route["count"], f"{route['weekday_ratio']*100:.0f}%",
route["first"], route["last"]]
for c, value in enumerate(vals): self.routes.setItem(r, c, _item(value))
self.route_label.setText(f"唯一区间 {len(classes)} · 通勤候选 {sum(r['category']=='通勤候选' for r in classes)}")
completion = analytics.network_completion(self._txs); self.completion.setRowCount(0)
for row in completion[:50]:
r = self.completion.rowCount(); self.completion.insertRow(r)
vals = [row["line"], row["visited"], row["total"], f"{row['ratio']*100:.1f}%"]
for c, value in enumerate(vals): self.completion.setItem(r, c, _item(value))
def _refresh_calendar(self):
for qdate in self._calendar_dates:
self.calendar.setDateTextFormat(qdate, QTextCharFormat())
self._calendar_dates = []
days = analytics.calendar_stats(self._txs, self.year.value())
self.calendar.setCurrentPage(self.year.value(), self.calendar.monthShown())
max_trips = max([v["trips"] for v in days.values()] or [1])
for day, info in days.items():
d = datetime.date.fromisoformat(day); qd = QDate(d.year, d.month, d.day)
fmt = QTextCharFormat(); alpha = 70 + int(185 * info["trips"] / max_trips)
fmt.setBackground(QColor(60, 255, 106, alpha)); fmt.setForeground(QColor(BG))
fmt.setToolTip(f"{info['trips']} 趟 · {len(info['stations'])} 站")
self.calendar.setDateTextFormat(qd, fmt); self._calendar_dates.append(qd)
self.calendar_info.setText(f"{len(days)} 个活跃日")
def _safe(self, title, action):
try:
return action()
except Exception as exc:
QMessageBox.warning(self, title, str(exc))
return None
def _refresh_archive(self):
stats = history_store.database_stats(); self.archive_label.setText(
f"SQLite v{stats['schema_version']} · {stats['cards']} 卡 / {stats['transactions']} 记录 / "
f"{stats['scans']} 快照 / {stats['raw_blocks']} 原始块 · {stats['size_bytes']/1024:.1f} KB")
scans = history_store.list_scans(limit=200); self.scans.setRowCount(0)
for scan in scans:
r = self.scans.rowCount(); self.scans.insertRow(r)
vals = [scan["read_at"], scan["card_id"], scan["record_count"],
scan["added_count"], scan["parser_version"]]
for c, value in enumerate(vals): self.scans.setItem(r, c, _item(value))
def _backup(self):
ext = "*.neko" if self.encrypt.isChecked() else "*.zip"
default = f"NekoBackup_{datetime.datetime.now():%Y%m%d_%H%M%S}." + ("neko" if self.encrypt.isChecked() else "zip")
path, _ = QFileDialog.getSaveFileName(self, "创建备份", default, f"Neko Backup ({ext})")
if path:
result = self._safe("备份失败", lambda: data_portability.create_bundle(
path, encrypted=self.encrypt.isChecked()))
if result:
QMessageBox.information(self, "备份完成", path)
def _restore(self):
path, _ = QFileDialog.getOpenFileName(self, "恢复备份", "", "Neko Backup (*.neko *.zip)")
if not path: return
if QMessageBox.question(self, "恢复备份", "恢复前会自动保存当前数据库,继续吗?") != QMessageBox.StandardButton.Yes:
return
result = self._safe("恢复失败", lambda: data_portability.restore_bundle(path))
if not result:
return
QMessageBox.information(self, "恢复完成", f"当前记录:{result['stats']['transactions']}\n恢复前备份:{result['emergency_backup']}")
self.data_changed.emit(); self.refresh()
def _undo_scan(self):
card_id = self._card_id if self._card_id and self._card_id != history_store.COMBINED else None
if QMessageBox.question(self, "撤销读取", "撤销最近一次读取中新增加的记录?") != QMessageBox.StandardButton.Yes:
return
result = self._safe("撤销失败", lambda: history_store.undo_last_scan(card_id))
if result is None:
return
QMessageBox.information(self, "撤销读取", f"已移除 {result['removed']} 条新增记录")
self.data_changed.emit(); self.refresh()
def _export_json(self):
path, _ = QFileDialog.getSaveFileName(self, "导出 JSON", "NekoHistory.json", "JSON (*.json)")
if path: self._safe("导出失败", lambda: history_store.export_json(path))
def _yearbook(self):
path, _ = QFileDialog.getSaveFileName(
self, "导出交通年鉴", f"NekoYearbook_{self.year.value()}.html", "HTML (*.html)")
if path:
result = self._safe("年鉴导出失败", lambda: yearbook_export.export(
path, self._txs, self.year.value()))
if result:
QMessageBox.information(self, "年鉴已生成", path)
def _refresh_plugins(self, force=False):
plugin_registry.discover(force=force); self._refresh_diag()
def _refresh_diag(self):
report = diagnostics.format_report(); status = plugin_registry.status()
lines = [report, "", f"Plugin directory: {status['directory']}"]
lines += [f" [OK] {p['name']} v{p['version']}" for p in status["plugins"]]
lines += [f" [!!] {e['name']}: {e['error']}" for e in status["errors"]]
self.diag.setPlainText("\n".join(lines))
def _export_map_pack(self):
path, _ = QFileDialog.getSaveFileName(self, "导出离线地图包", "NekoOfflineMaps.zip", "ZIP (*.zip)")
if path:
result = self._safe("导出失败", lambda: offline_maps.export_pack(path))
if result:
QMessageBox.information(self, "导出完成", path)
def _import_map_pack(self):
path, _ = QFileDialog.getOpenFileName(self, "导入离线地图包", "", "ZIP (*.zip)")
if path:
result = self._safe("导入失败", lambda: offline_maps.import_pack(path))
if result:
QMessageBox.information(self, "导入完成", f"已导入 {result['files']} 个缓存文件")