From ddfff2e8266d01a9a92134e8254b87eda0725f5e Mon Sep 17 00:00:00 2001 From: Suntion <149924916+SunYanbox@users.noreply.github.com> Date: Tue, 5 May 2026 17:23:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(audit):=20=E9=87=8D=E6=9E=84=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E6=A0=87=E7=AD=BE=E9=A1=B5=E6=94=AF=E6=8C=81=E5=8F=8C?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E4=B8=8E=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增功能: 待审核与已审核历史双标签页视图 * 引入 `TabbedContent` 和 `TabPane` 组件,分离“待审核的更改”与“已审核历史”逻辑 * 新增 `ConfirmScreen` 模态确认对话框,用于批量操作的二次确认 * 实现 `_batch_all` 方法,支持一键同意/拒绝所有待审核更改 * 实现 `_batch_file` 方法,支持针对单个文件内的多个快照进行批量处理 * 在历史标签页增加过滤按钮 (`history-filter-all`, `history-filter-approved`, `history-filter-rejected`) - 修复问题: 优化列表刷新与状态反馈机制 * 将 UI 重建逻辑从 `on_mount` 移至异步方法 `_refresh_pending` 和 `_refresh_history` * 使用 `#pending-list` 和 `#history-list` 容器动态挂载子组件,替代直接 `mount` 到父级 * 结果日志区域 `#audit-result-log` 现通过 `_append_result` 统一追加消息并自动滚动 - 重构优化: 核心业务逻辑解耦与扩展 * 在 `AuditCommitter` 中新增 `batch_commit` 方法,返回 `(snapshot_id, result_message)` 列表 * 更新 `AuditTab.on_button_pressed` 事件分发逻辑,区分全局批量、文件级批量及单条操作 * 引入 `_file_batch_map` 字典维护文件索引与快照 ID 的映射关系,支持按文件索引快速定位 --- src/console/ui/widgets/audit_tab.py | 431 ++++++++++++++++++++++++---- src/core/audit_committer.py | 16 ++ 2 files changed, 393 insertions(+), 54 deletions(-) diff --git a/src/console/ui/widgets/audit_tab.py b/src/console/ui/widgets/audit_tab.py index 3233a89..82b39d7 100644 --- a/src/console/ui/widgets/audit_tab.py +++ b/src/console/ui/widgets/audit_tab.py @@ -1,20 +1,75 @@ -"""审核标签页 — 显示待审核的文件快照.""" +"""审核标签页 — 待审核与已审核历史双标签页视图.""" from __future__ import annotations from collections import defaultdict +from datetime import datetime from typing import ClassVar from rich.markup import escape from textual.containers import Horizontal, Vertical -from textual.widgets import Button, Collapsible, Label, Static +from textual.screen import Screen +from textual.widgets import Button, Collapsible, Label, Static, TabbedContent, TabPane + + +class ConfirmScreen(Screen[bool]): + """模态确认对话框.""" + + DEFAULT_CSS = """ + ConfirmScreen { + align: center middle; + } + + #confirm-dialog { + width: 50; + height: auto; + padding: 2 3; + background: $surface; + border: thick $primary; + } + + #confirm-message { + text-align: center; + margin-bottom: 1; + } + + #confirm-buttons { + height: auto; + align: center middle; + } + + #confirm-buttons Button { + margin: 0 1; + } + """ + + def __init__(self, message: str) -> None: + super().__init__() + self._message = message + + def compose(self): + with Vertical(id="confirm-dialog"): + yield Static(self._message, id="confirm-message") + with Horizontal(id="confirm-buttons"): + yield Button("确认", variant="primary", id="confirm-yes") + yield Button("取消", variant="default", id="confirm-no") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm-yes": + self.dismiss(True) + else: + self.dismiss(False) + + def key_escape(self) -> None: + self.dismiss(False) class AuditTab(Vertical): """审核标签页. - 显示所有 PENDING_AUDIT 的文件快照, - 每个文件作为一个可折叠块,含 diff 内容和批准/拒绝按钮. + 包含两个子标签页: + - 待审核的更改: 显示所有 PENDING_AUDIT 的文件快照 + - 已审核历史: 显示所有已批准/拒绝的审核记录 """ DEFAULT_CSS: ClassVar[str] = """ @@ -25,23 +80,32 @@ class AuditTab(Vertical): overflow-y: auto; } - #audit-placeholder { - height: 100%; - content-align: center middle; - color: $text-muted; + AuditTab TabbedContent { + height: 1fr; + } + + /* ---- Pending tab ---- */ + + #pending-header { + height: auto; + padding: 1 0; + text-style: bold; + color: $text; } - #audit-empty { + #pending-empty { height: 100%; content-align: center middle; color: $text-muted; } - #audit-header { + #audit-result-log { height: auto; - padding: 1 0; - text-style: bold; - color: $text; + max-height: 6; + overflow-y: auto; + border: solid $accent; + padding: 0 1; + margin-bottom: 1; } .audit-collapsible { @@ -73,22 +137,116 @@ class AuditTab(Vertical): margin-right: 1; } - #audit-result-log { + /* ---- Batch buttons ---- */ + + .batch-buttons { + height: auto; + align: left middle; + margin-bottom: 1; + padding: 1 0; + } + + .batch-buttons Button { + margin-right: 1; + } + + .file-batch-buttons { + height: auto; + align: left middle; + margin-bottom: 1; + } + + .file-batch-buttons Button { + margin-right: 1; + } + + /* ---- History tab ---- */ + + .filter-buttons { + height: auto; + align: left middle; + margin-bottom: 1; + padding: 1 0; + } + + .filter-buttons Button { + margin-right: 1; + } + + #history-header { + height: auto; + padding: 1 0; + text-style: bold; + color: $text; + } + + #history-empty { + height: 100%; + content-align: center middle; + color: $text-muted; + } + + .history-entry { + height: auto; + margin-bottom: 1; + } + + .history-status { height: auto; - max-height: 6; - overflow-y: auto; - border: solid $accent; padding: 0 1; margin-bottom: 1; } + + .history-status-approved { + color: $success; + text-style: bold; + } + + .history-status-rejected { + color: $error; + text-style: bold; + } + + .history-diff { + height: auto; + max-height: 12; + overflow-y: auto; + padding: 1; + background: $surface; + border: solid $primary; + margin-bottom: 1; + } """ def __init__(self) -> None: super().__init__() self._committer = None + # Map: file_index -> (snapshot_ids, file_path) + self._file_batch_map: dict[int, tuple[list[int], str]] = {} + self._history_filter: str = "all" def compose(self): - yield Label("正在加载审核列表...", id="audit-placeholder") + with TabbedContent(): + with TabPane("待审核的更改", id="tab-pending"), Vertical(): + yield Horizontal( + Button("同意全部更改", variant="primary", id="batch-approve-all"), + Button("拒绝全部更改", variant="error", id="batch-reject-all"), + classes="batch-buttons", + ) + yield Vertical(id="audit-result-log") + yield Label("待审核更改", id="pending-header") + yield Vertical(id="pending-list") + with TabPane("已审核历史", id="tab-history"), Vertical(): + yield Horizontal( + Button("全部", id="history-filter-all"), + Button("已批准", id="history-filter-approved"), + Button("已拒绝", id="history-filter-rejected"), + classes="filter-buttons", + ) + yield Label("已审核历史", id="history-header") + yield Vertical(id="history-list") + + # -- Lifecycle -- def set_committer(self, committer) -> None: """设置审核提交模块并刷新列表.""" @@ -98,41 +256,65 @@ def set_committer(self, committer) -> None: def on_mount(self) -> None: """控件挂载后刷新.""" if self._committer is not None: - # 延迟一下让 UI 就绪 self.set_timer(0.1, self._refresh) + # -- Tab switching -- + async def _refresh(self) -> None: + """外部刷新入口 — 刷新待审核标签页.""" + await self._refresh_pending() + + async def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: + """内标签页切换时刷新对应内容.""" + if self._committer is None: + return + if event.pane.id == "tab-history": + await self._refresh_history() + elif event.pane.id == "tab-pending": + await self._refresh_pending() + + # -- Pending tab -- + + async def _refresh_pending(self) -> None: """查询待审核列表并重建 UI.""" if self._committer is None: return - await self.remove_children() + pending_list = self.query_one("#pending-list", Vertical) + await pending_list.remove_children() pending = self._committer.workspace.db.get_snapshots_by_audit_status("PENDING_AUDIT") if not pending: - await self.mount(Label("没有待审核的更改.", id="audit-empty")) + await self.query_one("#pending-header", Label).update("没有待审核的更改.") return - # Group by file_path grouped: defaultdict[str, list[tuple]] = defaultdict(list) for snap in pending: grouped[snap[1]].append(snap) - # Result area for showing commit results - result_log = Vertical(id="audit-result-log") - await self.mount(result_log) + total_count = sum(len(snaps) for snaps in grouped.values()) + await self.query_one("#pending-header", Label).update(f"待审核更改 ({total_count} 项)") - header = Label( - f"待审核更改 ({sum(len(snaps) for snaps in grouped.values())} 项)", - id="audit-header", - ) - await self.mount(header) + self._file_batch_map.clear() + file_index = 0 for file_path in sorted(grouped): snaps = grouped[file_path] - # Collect all children for all snaps of this file + snap_ids = [snap[0] for snap in snaps] + file_index += 1 + self._file_batch_map[file_index] = (snap_ids, file_path) + all_snap_widgets: list[Static | Horizontal] = [] + + # File-level batch buttons + file_batch_row = Horizontal( + Button("同意文件中所有更改", variant="primary", id=f"fapp-{file_index}", classes="audit-approve"), + Button("拒绝文件中所有更改", variant="error", id=f"frej-{file_index}", classes="audit-reject"), + classes="file-batch-buttons", + ) + all_snap_widgets.append(file_batch_row) + for snap in snaps: snap_id = snap[0] diff_content = snap[4] or "(空 diff)" @@ -153,44 +335,185 @@ async def _refresh(self) -> None: title=f"{file_path} ({len(snaps)} 次更改)", classes="audit-collapsible", ) - # Collapse excess items initially — keep first 3 expanded - if len(self.children) > 6: # header + result_log + 3 expanded - collapsible.collapsed = True - await self.mount(collapsible) + await pending_list.mount(collapsible) + + # -- History tab -- + + async def _refresh_history(self) -> None: + """查询已审核历史并重建 UI.""" + if self._committer is None: + return + + history_list = self.query_one("#history-list", Vertical) + await history_list.remove_children() + + db = self._committer.workspace.db + if self._history_filter == "approved": + history = db.get_snapshots_by_audit_status("APPROVED") + elif self._history_filter == "rejected": + history = db.get_snapshots_by_audit_status("REJECTED") + else: + approved = db.get_snapshots_by_audit_status("APPROVED") + rejected = db.get_snapshots_by_audit_status("REJECTED") + history = approved + rejected + history.sort(key=lambda x: x[5], reverse=True) + + if not history: + await self.query_one("#history-header", Label).update("没有已审核的记录.") + return + + filter_label = {"all": "全部", "approved": "已批准", "rejected": "已拒绝"} + await self.query_one("#history-header", Label).update( + f"已审核历史 ({filter_label[self._history_filter]}) — {len(history)} 项" + ) + + grouped: defaultdict[str, list[tuple]] = defaultdict(list) + for snap in history: + grouped[snap[1]].append(snap) + + for file_path in sorted(grouped): + snaps = grouped[file_path] + entry_widgets: list[Vertical] = [] + + for snap in snaps: + diff_content = snap[4] or "(空 diff)" + timestamp = snap[5] + audit_status = snap[7] + + time_str = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + status_text = "已批准" if audit_status == "APPROVED" else "已拒绝" + status_class = "history-status-approved" if audit_status == "APPROVED" else "history-status-rejected" + + entry_widgets.append( + Vertical( + Static( + f"[{status_class}]{status_text}[/{status_class}] — 审核时间: {time_str}", + classes="history-status", + ), + Static(diff_content, markup=False, classes="history-diff"), + classes="history-entry", + ) + ) + + content_widgets = Vertical(*entry_widgets) + collapsible = Collapsible( + content_widgets, + title=f"{file_path} ({len(snaps)} 项)", + classes="audit-collapsible", + ) + await history_list.mount(collapsible) + + # -- Result logging -- + + async def _append_result(self, message: str, color: str = "green") -> None: + """追加一条结果消息到结果日志.""" + try: + log = self.query_one("#audit-result-log", Vertical) + escaped = escape(message) + await log.mount(Static(f"[{color}]{escaped}[/{color}]")) + except Exception: + pass + + # -- Button dispatching -- async def on_button_pressed(self, event: Button.Pressed) -> None: - """处理批准/拒绝按钮点击.""" + """处理所有按钮点击.""" if self._committer is None: return button_id = event.button.id or "" + # History filter + if button_id == "history-filter-all": + self._history_filter = "all" + await self._refresh_history() + return + if button_id == "history-filter-approved": + self._history_filter = "approved" + await self._refresh_history() + return + if button_id == "history-filter-rejected": + self._history_filter = "rejected" + await self._refresh_history() + return + + # Global batch + if button_id == "batch-approve-all": + await self._batch_all(approved=True) + return + if button_id == "batch-reject-all": + await self._batch_all(approved=False) + return + + # Parse action-suffix parts = button_id.split("-", 1) if len(parts) != 2: return + action, suffix = parts + + # File-level batch + if action == "fapp": + idx = int(suffix) + data = self._file_batch_map.get(idx) + if data: + await self._batch_file(data[0], data[1], approved=True) + return + if action == "frej": + idx = int(suffix) + data = self._file_batch_map.get(idx) + if data: + await self._batch_file(data[0], data[1], approved=False) + return - action, snap_id_str = parts - try: - snapshot_id = int(snap_id_str) - except ValueError: + # Individual approve/reject + if action in ("approve", "reject"): + try: + snap_id = int(suffix) + except ValueError: + return + approved = action == "approve" + result = self._committer.commit(snap_id, approved) + color = "green" if "已批准" in result or "已拒绝" in result else "red" + await self._append_result(result, color) + await self._refresh() return - if action == "approve": - result = self._committer.commit(snapshot_id, approved=True) - elif action == "reject": - result = self._committer.commit(snapshot_id, approved=False) - else: + # -- Batch operations -- + + async def _batch_all(self, approved: bool) -> None: + """批量处理所有待审核更改.""" + action_text = "同意" if approved else "拒绝" + confirmed = await self.app.push_screen(ConfirmScreen(f"确定{action_text}所有待审核的更改?")) + if not confirmed: return - # Show result in the result log - try: - log = self.query_one("#audit-result-log", Vertical) + pending = self._committer.workspace.db.get_snapshots_by_audit_status("PENDING_AUDIT") + snap_ids = [snap[0] for snap in pending] + if not snap_ids: + await self._append_result("没有待审核的更改.", "red") + return - color = "green" if "已批准" in result or "已拒绝" in result else "red" - escaped = escape(result) - await log.mount(Static(f"[{color}]{escaped}[/{color}]")) - except Exception: - pass + results = self._committer.batch_commit(snap_ids, approved) + success = sum(1 for _, r in results if "已批准" in r or "已拒绝" in r) + color = "green" if success > 0 else "red" + await self._append_result( + f"批量{action_text}: {success} 项成功处理, {len(results) - success} 项失败", + color, + ) + await self._refresh() - # Refresh the list + async def _batch_file(self, snap_ids: list[int], file_path: str, approved: bool) -> None: + """批量处理单个文件中的所有待审核更改.""" + action_text = "同意" if approved else "拒绝" + confirmed = await self.app.push_screen(ConfirmScreen(f"确定{action_text}{file_path}中的所有更改?")) + if not confirmed: + return + + results = self._committer.batch_commit(snap_ids, approved) + success = sum(1 for _, r in results if "已批准" in r or "已拒绝" in r) + color = "green" if success > 0 else "red" + await self._append_result( + f"{file_path}: 批量{action_text} — {success} 项成功, {len(results) - success} 项失败", + color, + ) await self._refresh() diff --git a/src/core/audit_committer.py b/src/core/audit_committer.py index 5e8b916..cff22dd 100644 --- a/src/core/audit_committer.py +++ b/src/core/audit_committer.py @@ -90,3 +90,19 @@ def commit(self, snapshot_id: int, approved: bool = True) -> str: except Exception as e: return f"写入失败: {e.__class__.__name__}({e})" + + def batch_commit(self, snapshot_ids: list[int], approved: bool) -> list[tuple[int, str]]: + """批量审核多个快照. + + Args: + snapshot_ids: 快照 ID 列表 + approved: True=批准, False=拒绝 + + Returns: + (snapshot_id, result_message) 列表 + """ + results: list[tuple[int, str]] = [] + for snap_id in snapshot_ids: + result = self.commit(snap_id, approved) + results.append((snap_id, result)) + return results From 1bc112c2b8c40af21a9c2347a4bb18b669be9636 Mon Sep 17 00:00:00 2001 From: Suntion <149924916+SunYanbox@users.noreply.github.com> Date: Tue, 5 May 2026 17:31:15 +0800 Subject: [PATCH 2/2] Use enumerate for file indexing in audit_tab.py Change file iteration to use enumerate for indexing. --- src/console/ui/widgets/audit_tab.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/console/ui/widgets/audit_tab.py b/src/console/ui/widgets/audit_tab.py index 82b39d7..a7182b3 100644 --- a/src/console/ui/widgets/audit_tab.py +++ b/src/console/ui/widgets/audit_tab.py @@ -299,10 +299,10 @@ async def _refresh_pending(self) -> None: self._file_batch_map.clear() file_index = 0 - for file_path in sorted(grouped): + for file_index, file_path in enumerate(sorted(grouped), start=1): snaps = grouped[file_path] snap_ids = [snap[0] for snap in snaps] - file_index += 1 + self._file_batch_map[file_index] = (snap_ids, file_path) all_snap_widgets: list[Static | Horizontal] = []