初始提交
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from cli.tui.screens.browser import BrowserScreen
|
||||
from cli.tui.screens.dashboard import DashboardScreen
|
||||
from cli.tui.screens.detail import DetailScreen
|
||||
|
||||
__all__ = ['BrowserScreen', 'DashboardScreen', 'DetailScreen']
|
||||
@@ -0,0 +1,840 @@
|
||||
import asyncio
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Static
|
||||
|
||||
from cli.tui.actions import (
|
||||
TUI_ACTION_PRESENTATION_SERVICE,
|
||||
TUI_ACTION_REGISTRY,
|
||||
TuiActionResult,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.adapters import BrowserPageSnapshot, BrowserRecordSnapshot, DetailSectionSnapshot
|
||||
from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin
|
||||
from cli.tui.screens.interactions import TUI_SCREEN_INTERACTION_SERVICE, ScreenInteractionActionsMixin
|
||||
from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER
|
||||
from cli.tui.search import TUI_SEARCH_SERVICE
|
||||
from cli.tui.widgets import (
|
||||
NavigationItem,
|
||||
RecordNavigator,
|
||||
RecordSummaryView,
|
||||
SectionDetailView,
|
||||
SectionNavigator,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
|
||||
class BrowserScreenSupport:
|
||||
"""
|
||||
浏览页屏幕支持对象。
|
||||
|
||||
该对象负责浏览页摘要、搜索词、动作解析、空态/加载态分区构建和
|
||||
焦点顺序编排,使 `BrowserScreen` 本体主要保留 Textual 事件桥接。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_summary_text(snapshot: BrowserPageSnapshot, active_view: str) -> str:
|
||||
"""
|
||||
构建浏览页摘要文本。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:param active_view: 当前激活视图
|
||||
:return: 摘要文本
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.records)
|
||||
return TUI_COPY.build_browser_summary_text(
|
||||
total_count=summary.total_count,
|
||||
ok_count=summary.ok_count,
|
||||
warn_count=summary.warn_count,
|
||||
fail_count=summary.fail_count,
|
||||
action_hint=TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint(active_view),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_filter_bar_text(snapshot: BrowserPageSnapshot) -> str:
|
||||
"""
|
||||
构建当前浏览页筛选条文本。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:return: 筛选条文本
|
||||
"""
|
||||
search_context = snapshot.search
|
||||
return TUI_SEARCH_SERVICE.build_filter_bar_text(
|
||||
snapshot.filters,
|
||||
snapshot.active_filter_key,
|
||||
search_query=search_context.query if search_context is not None else '',
|
||||
search_placeholder=search_context.placeholder if search_context is not None else '',
|
||||
search_suggestions=search_context.suggestions if search_context is not None else [],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def current_search_query(snapshot: BrowserPageSnapshot) -> str:
|
||||
"""
|
||||
读取当前浏览页搜索词。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:return: 搜索词
|
||||
"""
|
||||
search_context = snapshot.search
|
||||
return search_context.query if search_context is not None else ''
|
||||
|
||||
@staticmethod
|
||||
def resolve_action(
|
||||
*,
|
||||
active_view: str,
|
||||
slot: str,
|
||||
record: BrowserRecordSnapshot,
|
||||
env: str,
|
||||
) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前页面指定槽位对应的动作。
|
||||
|
||||
:param active_view: 当前激活视图
|
||||
:param slot: 动作槽位
|
||||
:param record: 当前选中记录
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
return TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key=active_view,
|
||||
slot=slot, # type: ignore[arg-type]
|
||||
record=record,
|
||||
env=env,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_record_action_lines(
|
||||
*,
|
||||
active_view: str,
|
||||
record: BrowserRecordSnapshot,
|
||||
env: str,
|
||||
feedback_lines: list[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
构建当前记录动作面板文本。
|
||||
|
||||
:param active_view: 当前激活视图
|
||||
:param record: 当前记录
|
||||
:param env: 当前运行环境
|
||||
:param feedback_lines: 最近动作反馈文本
|
||||
:return: 动作面板文本
|
||||
"""
|
||||
action_lines = TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_lines(
|
||||
view_key=active_view,
|
||||
record=record,
|
||||
env=env,
|
||||
)
|
||||
return TUI_COPY.build_browser_action_panel_lines(action_lines, feedback_lines)
|
||||
|
||||
@staticmethod
|
||||
def build_empty_record() -> BrowserRecordSnapshot:
|
||||
"""
|
||||
构建浏览页空态兜底记录。
|
||||
|
||||
:return: 记录快照
|
||||
"""
|
||||
return BrowserRecordSnapshot(
|
||||
key='empty',
|
||||
title=TUI_COPY.build_browser_empty_record_copy('title'),
|
||||
status='info',
|
||||
summary=TUI_COPY.build_browser_empty_record_copy('summary'),
|
||||
metadata_lines=[],
|
||||
detail_sections=[
|
||||
DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_browser_empty_record_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=TUI_COPY.build_browser_empty_record_copy('label'),
|
||||
empty_value=TUI_COPY.build_browser_empty_record_copy('value'),
|
||||
detail=TUI_COPY.build_browser_empty_record_copy('detail'),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_loading_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建详情加载中的占位分区。
|
||||
|
||||
:return: 占位分区
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_browser_loading_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_loading_lines(
|
||||
loading_label=TUI_COPY.build_browser_loading_copy('label'),
|
||||
loading_value=TUI_COPY.build_browser_loading_copy('value'),
|
||||
detail=TUI_COPY.build_browser_loading_copy('detail'),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_detail_load_failure_sections(error: Exception, resource_name: str) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建详情加载失败时的兜底分区。
|
||||
|
||||
:param error: 后台加载异常
|
||||
:param resource_name: 资源名称
|
||||
:return: 失败分区列表
|
||||
"""
|
||||
return [
|
||||
DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_load_failure_section_title(resource_name),
|
||||
status='fail',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
|
||||
{
|
||||
'message': '后台详情加载失败',
|
||||
'error': str(error) or error.__class__.__name__,
|
||||
'hint': '可按 [R] 刷新当前页面,或切换记录后重试',
|
||||
},
|
||||
empty_label='详情状态',
|
||||
empty_value='不可用',
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_empty_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建浏览页详情空态兜底分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_detail_empty_section_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=TUI_COPY.build_detail_empty_section_copy('label'),
|
||||
empty_value=TUI_COPY.build_detail_empty_section_copy('value'),
|
||||
detail=TUI_COPY.build_detail_empty_section_copy('detail_record'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BrowserScreenFocusService(BaseScreenFocusService):
|
||||
"""
|
||||
浏览页焦点服务。
|
||||
|
||||
该对象负责定义浏览页可聚焦组件顺序,以及按左右键和滚动键解析
|
||||
当前目标区域。
|
||||
"""
|
||||
|
||||
def get_focus_order(
|
||||
self,
|
||||
screen: 'BrowserScreen',
|
||||
) -> list[WorkspaceSidebar | RecordNavigator | SectionNavigator | RecordSummaryView | SectionDetailView]:
|
||||
"""
|
||||
定义浏览页可通过左右键切换的焦点顺序。
|
||||
|
||||
:param screen: 当前浏览页屏幕
|
||||
:return: 焦点顺序列表
|
||||
"""
|
||||
return [
|
||||
screen.query_one(WorkspaceSidebar),
|
||||
screen.query_one(RecordNavigator),
|
||||
screen.query_one(SectionNavigator),
|
||||
screen.query_one(RecordSummaryView),
|
||||
screen.query_one(SectionDetailView),
|
||||
]
|
||||
|
||||
def get_default_scroll_target(
|
||||
self,
|
||||
screen: 'BrowserScreen',
|
||||
) -> ScrollableContainer:
|
||||
"""
|
||||
返回浏览页默认滚动容器。
|
||||
|
||||
:param screen: 当前浏览页屏幕
|
||||
:return: 主工作区滚动容器
|
||||
"""
|
||||
return screen.query_one('#workspace-main', ScrollableContainer)
|
||||
|
||||
|
||||
class BrowserScreen(ScreenInteractionActionsMixin, ScreenFocusActionsMixin, Screen[None]):
|
||||
"""
|
||||
TUI 浏览型页面。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False),
|
||||
Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False),
|
||||
Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False),
|
||||
Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False),
|
||||
Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False),
|
||||
Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False),
|
||||
Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False),
|
||||
Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False),
|
||||
Binding('x', 'trigger_primary_action', TUI_COPY.build_internal_binding_label('action_primary'), show=False),
|
||||
Binding('z', 'trigger_secondary_action', TUI_COPY.build_internal_binding_label('action_secondary'), show=False),
|
||||
Binding('y', 'trigger_global_action', TUI_COPY.build_internal_binding_label('action_global'), show=False),
|
||||
Binding('w', 'trigger_utility_action', TUI_COPY.build_internal_binding_label('action_utility'), show=False),
|
||||
Binding('1', 'apply_filter_1', TUI_COPY.build_internal_binding_label('filter_1'), show=False),
|
||||
Binding('2', 'apply_filter_2', TUI_COPY.build_internal_binding_label('filter_2'), show=False),
|
||||
Binding('3', 'apply_filter_3', TUI_COPY.build_internal_binding_label('filter_3'), show=False),
|
||||
Binding('4', 'apply_filter_4', TUI_COPY.build_internal_binding_label('filter_4'), show=False),
|
||||
Binding('/', 'open_search', TUI_COPY.build_internal_binding_label('search'), show=False),
|
||||
Binding('backspace', 'clear_search', TUI_COPY.build_internal_binding_label('clear_search'), show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: BrowserPageSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
refreshed_at: str,
|
||||
action_feedback_lines: list[str] | None = None,
|
||||
support: BrowserScreenSupport | None = None,
|
||||
focus_service: BrowserScreenFocusService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化浏览型页面。
|
||||
|
||||
:param snapshot: 浏览页快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
:param action_feedback_lines: 最近动作反馈文本
|
||||
:param support: 浏览页屏幕支持对象
|
||||
:param focus_service: 浏览页焦点服务
|
||||
:return: None
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
self.selected_record_index = 0
|
||||
self.selected_section_index = 0
|
||||
self._is_syncing_sections = False
|
||||
self._record_detail_request_id = 0
|
||||
self._record_detail_task: asyncio.Task[None] | None = None
|
||||
self._action_task: asyncio.Task[None] | None = None
|
||||
self._last_action_result: TuiActionResult | None = None
|
||||
self._action_feedback_lines = list(action_feedback_lines or [])
|
||||
self.support = support or BrowserScreenSupport()
|
||||
self.focus_service = focus_service or BrowserScreenFocusService()
|
||||
super().__init__()
|
||||
|
||||
def _build_summary_text(self) -> str:
|
||||
"""
|
||||
构建浏览页摘要文本。
|
||||
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return self.support.build_summary_text(self.snapshot, self.active_view)
|
||||
|
||||
def _build_filter_bar_text(self) -> str:
|
||||
"""
|
||||
构建当前浏览页筛选条文本。
|
||||
|
||||
:return: 筛选条文本
|
||||
"""
|
||||
return self.support.build_filter_bar_text(self.snapshot)
|
||||
|
||||
def _current_search_query(self) -> str:
|
||||
return self.support.current_search_query(self.snapshot)
|
||||
|
||||
def _apply_filter_shortcut(self, shortcut: str) -> None:
|
||||
"""
|
||||
按快捷键切换当前浏览页筛选条件。
|
||||
|
||||
:param shortcut: 筛选快捷键
|
||||
:return: None
|
||||
"""
|
||||
if not self.snapshot.filters:
|
||||
return
|
||||
target = next((option for option in self.snapshot.filters if option.shortcut == shortcut), None)
|
||||
if target is None or target.key == self.snapshot.active_filter_key:
|
||||
return
|
||||
remember_filter = getattr(self.app, 'remember_browser_filter', None)
|
||||
if callable(remember_filter):
|
||||
remember_filter(self.active_view, target.key)
|
||||
self.app.action_refresh_current_view()
|
||||
|
||||
def _open_search(self) -> None:
|
||||
"""
|
||||
打开当前浏览页搜索输入弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.open_search_dialog(
|
||||
self,
|
||||
self.snapshot.search,
|
||||
self._handle_search_submitted,
|
||||
)
|
||||
|
||||
def _handle_search_submitted(self, query: str | None) -> None:
|
||||
"""
|
||||
处理页内搜索输入结果。
|
||||
|
||||
:param query: 搜索词
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.remember_query_and_refresh(self, self.active_view, query)
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
"""
|
||||
清空当前浏览页搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.clear_query_and_refresh(
|
||||
self,
|
||||
self.active_view,
|
||||
self._current_search_query(),
|
||||
)
|
||||
|
||||
def _resolve_action(self, slot: str) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前页面指定槽位对应的动作。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: 动作定义
|
||||
"""
|
||||
return self.support.resolve_action(
|
||||
active_view=self.active_view,
|
||||
slot=slot,
|
||||
record=self._get_record_or_fallback(self.selected_record_index),
|
||||
env=self.env,
|
||||
)
|
||||
|
||||
def _build_record_action_lines(self, record: BrowserRecordSnapshot) -> list[str]:
|
||||
"""
|
||||
构建当前记录动作面板文本。
|
||||
|
||||
:param record: 当前记录
|
||||
:return: 动作面板文本
|
||||
"""
|
||||
return self.support.build_record_action_lines(
|
||||
active_view=self.active_view,
|
||||
record=record,
|
||||
env=self.env,
|
||||
feedback_lines=self._action_feedback_lines,
|
||||
)
|
||||
|
||||
def _open_action_confirm(self, slot: str) -> None:
|
||||
"""
|
||||
打开指定槽位动作的确认弹窗。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: None
|
||||
"""
|
||||
self._action_task = TUI_SCREEN_INTERACTION_SERVICE.confirm_and_schedule_action(
|
||||
self,
|
||||
self._resolve_action(slot),
|
||||
self._action_task,
|
||||
self._execute_action,
|
||||
)
|
||||
|
||||
async def _execute_action(self, action: TuiActionSpec) -> None:
|
||||
"""
|
||||
执行低风险动作并在结束后通知与刷新页面。
|
||||
|
||||
:param action: 动作定义
|
||||
:return: None
|
||||
"""
|
||||
|
||||
async def _handle_result(result: TuiActionResult, feedback_lines: list[str]) -> None:
|
||||
self._last_action_result = result
|
||||
self._action_feedback_lines = feedback_lines
|
||||
await self._render_record_detail(eager=False)
|
||||
|
||||
await TUI_SCREEN_INTERACTION_SERVICE.execute_action_with_feedback(
|
||||
self,
|
||||
action,
|
||||
self.env,
|
||||
self.active_view,
|
||||
_handle_result,
|
||||
)
|
||||
|
||||
def _get_record_or_fallback(self, index: int) -> BrowserRecordSnapshot:
|
||||
"""
|
||||
获取指定索引的记录快照,缺失时返回兜底记录。
|
||||
|
||||
:param index: 记录索引
|
||||
:return: 记录快照
|
||||
"""
|
||||
if 0 <= index < len(self.snapshot.records):
|
||||
return self.snapshot.records[index]
|
||||
return self.support.build_empty_record()
|
||||
|
||||
def _build_loading_section(self) -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建详情加载中的占位分区。
|
||||
|
||||
:return: 占位分区
|
||||
"""
|
||||
return self.support.build_loading_section()
|
||||
|
||||
def _build_detail_load_failure_sections(self, error: Exception) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
构建详情加载失败时的兜底分区。
|
||||
|
||||
:param error: 后台加载异常
|
||||
:return: 失败分区列表
|
||||
"""
|
||||
resource_name = self.snapshot.title if '详情' in self.snapshot.title else f'{self.snapshot.title}详情'
|
||||
return self.support.build_detail_load_failure_sections(error, resource_name)
|
||||
|
||||
@staticmethod
|
||||
def _cancel_task(task: asyncio.Task[None] | None) -> None:
|
||||
"""
|
||||
取消指定后台任务。
|
||||
|
||||
:param task: 待取消任务
|
||||
:return: None
|
||||
"""
|
||||
if task is None or task.done():
|
||||
return
|
||||
task.cancel()
|
||||
|
||||
def _cancel_background_tasks(self) -> None:
|
||||
"""
|
||||
取消当前屏幕持有的后台任务。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._record_detail_request_id += 1
|
||||
self._cancel_task(self._record_detail_task)
|
||||
self._cancel_task(self._action_task)
|
||||
|
||||
def _get_sections_for_record(self, index: int, *, eager: bool = False) -> list[DetailSectionSnapshot]:
|
||||
"""
|
||||
获取指定记录对应的详情分区列表。
|
||||
|
||||
:param index: 记录索引
|
||||
:param eager: 是否立即加载按需详情
|
||||
:return: 详情分区列表
|
||||
"""
|
||||
record = self._get_record_or_fallback(index)
|
||||
if eager:
|
||||
try:
|
||||
detail_sections = record.resolve_detail_sections()
|
||||
except Exception as error:
|
||||
detail_sections = self._build_detail_load_failure_sections(error)
|
||||
elif record.detail_sections:
|
||||
detail_sections = record.detail_sections
|
||||
elif record._cached_detail_sections:
|
||||
detail_sections = list(record._cached_detail_sections)
|
||||
elif record.detail_loader is not None:
|
||||
detail_sections = [self._build_loading_section()]
|
||||
else:
|
||||
detail_sections = []
|
||||
return [*detail_sections, *self.snapshot.shared_sections]
|
||||
|
||||
def _get_section_or_fallback(self, record_index: int, section_index: int) -> DetailSectionSnapshot:
|
||||
"""
|
||||
获取指定记录下的分区快照,缺失时返回兜底分区。
|
||||
|
||||
:param record_index: 记录索引
|
||||
:param section_index: 分区索引
|
||||
:return: 分区快照
|
||||
"""
|
||||
sections = self._get_sections_for_record(record_index)
|
||||
if 0 <= section_index < len(sections):
|
||||
return sections[section_index]
|
||||
return self.support.build_empty_section()
|
||||
|
||||
async def _render_record_detail(self, *, eager: bool = False) -> None:
|
||||
"""
|
||||
根据当前选中记录刷新右侧详情区域。
|
||||
|
||||
:param eager: 是否立即解析完整详情
|
||||
:return: None
|
||||
"""
|
||||
record = self._get_record_or_fallback(self.selected_record_index)
|
||||
sections = self._get_sections_for_record(self.selected_record_index, eager=eager)
|
||||
selected_section = self._get_section_or_fallback(self.selected_record_index, self.selected_section_index)
|
||||
self.query_one(RecordSummaryView).show_record(
|
||||
record,
|
||||
selected_section,
|
||||
self._build_record_action_lines(record),
|
||||
self._current_search_query(),
|
||||
)
|
||||
navigator = self.query_one(SectionNavigator)
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
self._is_syncing_sections = True
|
||||
try:
|
||||
await navigator.show_sections(
|
||||
sections,
|
||||
initial_index=self.selected_section_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
detail_view.show_section(selected_section, query=self._current_search_query())
|
||||
self._sync_detail_pane_state(selected_section)
|
||||
finally:
|
||||
self._is_syncing_sections = False
|
||||
|
||||
def _sync_detail_pane_state(self, section: DetailSectionSnapshot) -> None:
|
||||
"""
|
||||
依据当前分区状态同步右侧详情容器样式。
|
||||
|
||||
:param section: 当前分区快照
|
||||
:return: None
|
||||
"""
|
||||
detail_pane = self.query_one('#browser-detail-pane', Vertical)
|
||||
detail_pane.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info')
|
||||
normalized = section.status if section.status in {'ok', 'fail', 'warn'} else 'info'
|
||||
detail_pane.add_class(f'is-{normalized}')
|
||||
|
||||
async def _load_record_detail_async(self, index: int, request_id: int) -> None:
|
||||
"""
|
||||
在后台线程中加载指定记录的详情,并在仍为当前选中记录时回填界面。
|
||||
|
||||
:param index: 记录索引
|
||||
:param request_id: 当前加载请求编号
|
||||
:return: None
|
||||
"""
|
||||
record = self._get_record_or_fallback(index)
|
||||
try:
|
||||
await asyncio.to_thread(record.resolve_detail_sections)
|
||||
except Exception as error:
|
||||
failure_sections = self._build_detail_load_failure_sections(error)
|
||||
object.__setattr__(record, '_cached_detail_sections', tuple(failure_sections))
|
||||
if request_id != self._record_detail_request_id:
|
||||
return
|
||||
if index != self.selected_record_index:
|
||||
return
|
||||
await self._render_record_detail(eager=False)
|
||||
|
||||
def _schedule_record_detail_load(self, index: int) -> None:
|
||||
"""
|
||||
为当前选中记录安排一次后台详情加载。
|
||||
|
||||
:param index: 记录索引
|
||||
:return: None
|
||||
"""
|
||||
record = self._get_record_or_fallback(index)
|
||||
if record.detail_loader is None:
|
||||
return
|
||||
if record._cached_detail_sections:
|
||||
return
|
||||
self._cancel_task(self._record_detail_task)
|
||||
self._record_detail_request_id += 1
|
||||
self._record_detail_task = asyncio.create_task(
|
||||
self._load_record_detail_async(index, self._record_detail_request_id)
|
||||
)
|
||||
|
||||
async def _update_selected_record(self, index: int) -> None:
|
||||
"""
|
||||
更新当前选中记录,并同步右侧详情视图。
|
||||
|
||||
:param index: 待选中的记录索引
|
||||
:return: None
|
||||
"""
|
||||
if not self.snapshot.records:
|
||||
self.selected_record_index = 0
|
||||
self.selected_section_index = 0
|
||||
await self._render_record_detail(eager=False)
|
||||
return
|
||||
if not 0 <= index < len(self.snapshot.records):
|
||||
return
|
||||
if index == self.selected_record_index:
|
||||
return
|
||||
self.selected_record_index = index
|
||||
self.selected_section_index = 0
|
||||
await self._render_record_detail(eager=False)
|
||||
self._schedule_record_detail_load(index)
|
||||
|
||||
def _update_selected_section(self, index: int) -> None:
|
||||
"""
|
||||
更新当前选中分区,并同步最右侧详情内容。
|
||||
|
||||
:param index: 分区索引
|
||||
:return: None
|
||||
"""
|
||||
if self._is_syncing_sections:
|
||||
return
|
||||
sections = self._get_sections_for_record(self.selected_record_index)
|
||||
if not sections:
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
fallback = self._get_section_or_fallback(self.selected_record_index, index)
|
||||
detail_view.show_section(fallback, query=self._current_search_query())
|
||||
record = self._get_record_or_fallback(self.selected_record_index)
|
||||
self.query_one(RecordSummaryView).show_record(
|
||||
record,
|
||||
fallback,
|
||||
self._build_record_action_lines(record),
|
||||
self._current_search_query(),
|
||||
)
|
||||
self._sync_detail_pane_state(fallback)
|
||||
return
|
||||
if not 0 <= index < len(sections):
|
||||
return
|
||||
if index == self.selected_section_index:
|
||||
return
|
||||
self.selected_section_index = index
|
||||
current_section = sections[index]
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
detail_view.show_section(current_section, query=self._current_search_query())
|
||||
record = self._get_record_or_fallback(self.selected_record_index)
|
||||
self.query_one(RecordSummaryView).show_record(
|
||||
record,
|
||||
current_section,
|
||||
self._build_record_action_lines(record),
|
||||
self._current_search_query(),
|
||||
)
|
||||
self._sync_detail_pane_state(current_section)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
浏览页挂载后默认将焦点停留在左侧导航。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one(WorkspaceSidebar).focus)
|
||||
self._schedule_record_detail_load(self.selected_record_index)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""
|
||||
浏览页卸载时取消尚未完成的后台任务。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._cancel_background_tasks()
|
||||
|
||||
def action_apply_filter_1(self) -> None:
|
||||
"""
|
||||
应用第一个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('1')
|
||||
|
||||
def action_apply_filter_2(self) -> None:
|
||||
"""
|
||||
应用第二个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('2')
|
||||
|
||||
def action_apply_filter_3(self) -> None:
|
||||
"""
|
||||
应用第三个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('3')
|
||||
|
||||
def action_apply_filter_4(self) -> None:
|
||||
"""
|
||||
应用第四个浏览页筛选条件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._apply_filter_shortcut('4')
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧边栏事件打开对应视图,并避免重复打开当前页面。
|
||||
|
||||
:param event: 侧边栏高亮或选中事件
|
||||
:return: None
|
||||
"""
|
||||
item = getattr(event.item, 'item', None)
|
||||
if not isinstance(item, NavigationItem):
|
||||
return
|
||||
if item.view_key == self.active_view:
|
||||
return
|
||||
self.app.open_view(item.view_key)
|
||||
|
||||
@on(WorkspaceSidebar.Selected)
|
||||
def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
响应左侧导航选中事件并切换视图。
|
||||
|
||||
:param event: 导航选中事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(WorkspaceSidebar.Highlighted)
|
||||
def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None:
|
||||
"""
|
||||
响应左侧导航高亮变更并立即切换视图。
|
||||
|
||||
:param event: 导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建浏览页界面结构。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
yield WorkspaceHeader(self.env, self.active_view)
|
||||
with Horizontal(id='workspace-shell'):
|
||||
yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view)
|
||||
with ScrollableContainer(id='workspace-main'):
|
||||
yield WorkspaceHero(
|
||||
title=self.snapshot.title,
|
||||
subtitle=self.snapshot.subtitle,
|
||||
env=self.env,
|
||||
active_view=self.active_view,
|
||||
summary=self._build_summary_text(),
|
||||
refreshed_at=self.refreshed_at,
|
||||
)
|
||||
if self.snapshot.filters:
|
||||
yield Static(self._build_filter_bar_text(), id='browser-filter-bar', markup=False)
|
||||
with Horizontal(id='browser-body'):
|
||||
yield RecordNavigator(
|
||||
self.snapshot.records,
|
||||
initial_index=self.selected_record_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
with Vertical(id='browser-detail-pane'):
|
||||
yield RecordSummaryView(self._get_record_or_fallback(self.selected_record_index))
|
||||
with Horizontal(id='browser-section-body'):
|
||||
yield SectionNavigator(
|
||||
self._get_sections_for_record(self.selected_record_index, eager=False),
|
||||
initial_index=self.selected_section_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield SectionDetailView(
|
||||
self._get_section_or_fallback(
|
||||
self.selected_record_index,
|
||||
self.selected_section_index,
|
||||
),
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
@on(RecordNavigator.Changed)
|
||||
async def on_record_changed(self, event: RecordNavigator.Changed) -> None:
|
||||
"""
|
||||
响应记录导航高亮变更并更新详情内容。
|
||||
|
||||
:param event: 记录导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
await self._update_selected_record(event.index)
|
||||
|
||||
@on(SectionNavigator.Changed)
|
||||
def on_section_changed(self, event: SectionNavigator.Changed) -> None:
|
||||
"""
|
||||
响应记录详情分区高亮变更并更新最右侧详情内容。
|
||||
|
||||
:param event: 分区导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._update_selected_section(event.index)
|
||||
@@ -0,0 +1,128 @@
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
class ActionConfirmScreen(ModalScreen[bool]):
|
||||
"""
|
||||
通用动作确认弹窗。
|
||||
|
||||
:param title: 弹窗标题
|
||||
:param lines: 预览文本行
|
||||
:param confirm_label: 确认按钮文本
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
ActionConfirmScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#action-confirm-dialog {
|
||||
width: 96;
|
||||
max-width: 96;
|
||||
border: heavy #38d8ff;
|
||||
background: #06111b;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#action-confirm-title {
|
||||
text-style: bold;
|
||||
color: #e9fcff;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-confirm-body {
|
||||
border: round #18425d;
|
||||
background: #081827;
|
||||
color: #d8f7ff;
|
||||
padding: 1 2;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-confirm-actions {
|
||||
height: auto;
|
||||
align-horizontal: right;
|
||||
}
|
||||
|
||||
#action-confirm-hint {
|
||||
color: #8fc6d6;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#action-confirm-cancel {
|
||||
margin-right: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('escape', 'cancel', TUI_COPY.build_confirm_binding_label('cancel'), show=False),
|
||||
Binding('enter', 'submit', TUI_COPY.build_confirm_binding_label('submit'), show=False),
|
||||
]
|
||||
|
||||
def __init__(self, title: str, lines: list[str], confirm_label: str) -> None:
|
||||
super().__init__()
|
||||
self._dialog_title = title
|
||||
self._dialog_lines = lines
|
||||
self._confirm_label = confirm_label
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建确认弹窗界面。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
with Vertical(id='action-confirm-dialog'):
|
||||
yield Static(self._dialog_title, id='action-confirm-title', markup=False)
|
||||
yield Static('\n'.join(self._dialog_lines), id='action-confirm-body', markup=False)
|
||||
yield Static(TUI_COPY.build_action_confirm_hint(), id='action-confirm-hint', markup=False)
|
||||
with Horizontal(id='action-confirm-actions'):
|
||||
yield Button(TUI_COPY.build_action_confirm_cancel_label(), id='action-confirm-cancel')
|
||||
yield Button(self._confirm_label, id='action-confirm-submit', variant='primary')
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
弹窗挂载后默认聚焦确认按钮。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one('#action-confirm-submit', Button).focus)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""
|
||||
取消并关闭弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.dismiss(False)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
"""
|
||||
通过键盘确认执行当前动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
submit_button = self.query_one('#action-confirm-submit', Button)
|
||||
submit_button.press()
|
||||
|
||||
@on(Button.Pressed, '#action-confirm-cancel')
|
||||
def on_cancel_pressed(self) -> None:
|
||||
"""
|
||||
响应取消按钮。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.dismiss(False)
|
||||
|
||||
@on(Button.Pressed, '#action-confirm-submit')
|
||||
def on_submit_pressed(self) -> None:
|
||||
"""
|
||||
响应确认按钮。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.dismiss(True)
|
||||
@@ -0,0 +1,253 @@
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Grid, Horizontal, ScrollableContainer
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer
|
||||
|
||||
from cli.tui.adapters import DashboardSnapshot
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
|
||||
from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin
|
||||
from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER
|
||||
from cli.tui.widgets import (
|
||||
MetricPanel,
|
||||
NavigationItem,
|
||||
SignalRail,
|
||||
StatusPanel,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
PRIMARY_METRIC_COUNT = 2
|
||||
|
||||
|
||||
class DashboardScreenSupport:
|
||||
"""
|
||||
首页屏幕支持对象。
|
||||
|
||||
该对象负责首页摘要文本、信号带文本和状态轨迹构建,
|
||||
使 `DashboardScreen` 本体主要保留 Textual 生命周期与界面编排。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_summary_text(snapshot: DashboardSnapshot) -> str:
|
||||
"""
|
||||
构建首页摘要文本。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:return: 摘要文本
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.panels)
|
||||
return TUI_COPY.build_dashboard_summary_text(
|
||||
total_count=summary.total_count,
|
||||
ok_count=summary.ok_count,
|
||||
warn_count=summary.warn_count,
|
||||
fail_count=summary.fail_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_status_track(snapshot: DashboardSnapshot) -> str:
|
||||
"""
|
||||
构建首页面板状态轨迹字符串。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:return: 状态轨迹
|
||||
"""
|
||||
return ''.join(
|
||||
'x' if panel.status == 'fail' else '!' if panel.status == 'warn' else 'o' if panel.status == 'ok' else '-'
|
||||
for panel in snapshot.panels
|
||||
)
|
||||
|
||||
def build_signal_lines(self, snapshot: DashboardSnapshot) -> list[str]:
|
||||
"""
|
||||
构建首页顶部信号带文本。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:return: 信号文本行
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.panels)
|
||||
return TUI_COPY.build_dashboard_signal_lines(
|
||||
status_track=self.build_status_track(snapshot),
|
||||
fail_count=summary.fail_count,
|
||||
warn_count=summary.warn_count,
|
||||
ok_count=summary.ok_count,
|
||||
total_count=summary.total_count,
|
||||
navigation_shortcut_hint=TUI_KEYMAP_REGISTRY.navigation_shortcut_hint,
|
||||
)
|
||||
|
||||
|
||||
class DashboardScreenFocusService(BaseScreenFocusService):
|
||||
"""
|
||||
首页焦点服务。
|
||||
|
||||
该对象负责定义首页可聚焦组件顺序,以及按左右键和滚动键解析
|
||||
当前目标区域。
|
||||
"""
|
||||
|
||||
def get_focus_order(self, screen: 'DashboardScreen') -> list[WorkspaceSidebar | ScrollableContainer]:
|
||||
"""
|
||||
定义首页可通过左右键切换的焦点顺序。
|
||||
|
||||
:param screen: 当前首页屏幕
|
||||
:return: 焦点顺序列表
|
||||
"""
|
||||
return [
|
||||
screen.query_one(WorkspaceSidebar),
|
||||
screen.query_one('#workspace-main', ScrollableContainer),
|
||||
]
|
||||
|
||||
def get_default_scroll_target(self, screen: 'DashboardScreen') -> ScrollableContainer:
|
||||
"""
|
||||
返回首页默认滚动容器。
|
||||
|
||||
:param screen: 当前首页屏幕
|
||||
:return: 主工作区滚动容器
|
||||
"""
|
||||
return screen.query_one('#workspace-main', ScrollableContainer)
|
||||
|
||||
|
||||
class DashboardScreen(ScreenFocusActionsMixin, Screen[None]):
|
||||
"""
|
||||
TUI 首页巡检屏幕。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False),
|
||||
Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False),
|
||||
Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False),
|
||||
Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False),
|
||||
Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False),
|
||||
Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False),
|
||||
Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False),
|
||||
Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: DashboardSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
refreshed_at: str,
|
||||
support: DashboardScreenSupport | None = None,
|
||||
focus_service: DashboardScreenFocusService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化首页巡检屏幕。
|
||||
|
||||
:param snapshot: 首页聚合快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
:param support: 首页屏幕支持对象
|
||||
:param focus_service: 首页焦点服务
|
||||
:return: None
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
self.support = support or DashboardScreenSupport()
|
||||
self.focus_service = focus_service or DashboardScreenFocusService()
|
||||
super().__init__()
|
||||
|
||||
def _build_summary_text(self) -> str:
|
||||
"""
|
||||
构建首页摘要文本。
|
||||
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return self.support.build_summary_text(self.snapshot)
|
||||
|
||||
def _build_signal_lines(self) -> list[str]:
|
||||
"""
|
||||
构建首页顶部信号带文本。
|
||||
|
||||
:return: 信号文本行
|
||||
"""
|
||||
return self.support.build_signal_lines(self.snapshot)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建首页界面结构。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
yield WorkspaceHeader(self.env, self.active_view)
|
||||
with Horizontal(id='workspace-shell'):
|
||||
yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view)
|
||||
with ScrollableContainer(id='workspace-main'):
|
||||
yield WorkspaceHero(
|
||||
title=TUI_COPY.build_dashboard_hero_title(),
|
||||
subtitle=TUI_COPY.build_dashboard_hero_subtitle(),
|
||||
env=self.env,
|
||||
active_view=self.active_view,
|
||||
summary=self._build_summary_text(),
|
||||
refreshed_at=self.refreshed_at,
|
||||
)
|
||||
yield SignalRail(self._build_signal_lines())
|
||||
with Grid(id='dashboard-metrics'):
|
||||
for index, metric in enumerate(self.snapshot.metrics):
|
||||
yield MetricPanel(
|
||||
title=metric.title,
|
||||
value=metric.value,
|
||||
status=metric.status,
|
||||
hint=metric.hint,
|
||||
accent=index < PRIMARY_METRIC_COUNT,
|
||||
)
|
||||
with Grid(id='dashboard-grid'):
|
||||
for panel in self.snapshot.panels:
|
||||
yield StatusPanel(
|
||||
title=panel.title,
|
||||
status=panel.status,
|
||||
body='\n'.join(panel.lines),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
首页挂载后默认将焦点停留在左侧导航。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one(WorkspaceSidebar).focus)
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧边栏事件打开对应视图,并避免重复打开当前页面。
|
||||
|
||||
:param event: 侧边栏高亮或选中事件
|
||||
:return: None
|
||||
"""
|
||||
item = getattr(event.item, 'item', None)
|
||||
if not isinstance(item, NavigationItem):
|
||||
return
|
||||
if item.view_key == self.active_view:
|
||||
return
|
||||
self.app.open_view(item.view_key)
|
||||
|
||||
@on(WorkspaceSidebar.Selected)
|
||||
def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
响应左侧导航选中事件并切换视图。
|
||||
|
||||
:param event: 导航选中事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(WorkspaceSidebar.Highlighted)
|
||||
def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None:
|
||||
"""
|
||||
响应左侧导航高亮变更并立即切换视图。
|
||||
|
||||
:param event: 导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
@@ -0,0 +1,413 @@
|
||||
import asyncio
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, ScrollableContainer
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer
|
||||
|
||||
from cli.tui.actions import (
|
||||
TUI_ACTION_PRESENTATION_SERVICE,
|
||||
TUI_ACTION_REGISTRY,
|
||||
TuiActionResult,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.adapters import DetailPageSnapshot, DetailSectionSnapshot
|
||||
from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.screens.focus import BaseScreenFocusService, ScreenFocusActionsMixin
|
||||
from cli.tui.screens.interactions import TUI_SCREEN_INTERACTION_SERVICE, ScreenInteractionActionsMixin
|
||||
from cli.tui.screens.summary import STATUS_SUMMARY_BUILDER
|
||||
from cli.tui.widgets import (
|
||||
NavigationItem,
|
||||
SectionDetailView,
|
||||
SectionNavigator,
|
||||
WorkspaceHeader,
|
||||
WorkspaceHero,
|
||||
WorkspaceSidebar,
|
||||
)
|
||||
|
||||
|
||||
class DetailScreenSupport:
|
||||
"""
|
||||
详情页屏幕支持对象。
|
||||
|
||||
该对象负责详情页摘要、搜索词、动作解析与空态分区构建,
|
||||
使 `DetailScreen` 本体主要保留 Textual 事件桥接。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_empty_section() -> DetailSectionSnapshot:
|
||||
"""
|
||||
构建详情页空态兜底分区。
|
||||
|
||||
:return: 分区快照
|
||||
"""
|
||||
return DetailSectionSnapshot(
|
||||
title=TUI_COPY.build_detail_empty_section_copy('title'),
|
||||
status='info',
|
||||
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
|
||||
empty_label=TUI_COPY.build_detail_empty_section_copy('label'),
|
||||
empty_value=TUI_COPY.build_detail_empty_section_copy('value'),
|
||||
detail=TUI_COPY.build_detail_empty_section_copy('detail_page'),
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_summary_text(snapshot: DetailPageSnapshot, active_view: str) -> str:
|
||||
"""
|
||||
构建详情页摘要文本。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:param active_view: 当前激活视图
|
||||
:return: 摘要文本
|
||||
"""
|
||||
summary = STATUS_SUMMARY_BUILDER.build(snapshot.sections)
|
||||
return (
|
||||
TUI_COPY.build_detail_summary_text(
|
||||
total_count=summary.total_count,
|
||||
ok_count=summary.ok_count,
|
||||
warn_count=summary.warn_count,
|
||||
fail_count=summary.fail_count,
|
||||
)
|
||||
+ f' {TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint(active_view)}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def current_search_query(snapshot: DetailPageSnapshot) -> str:
|
||||
"""
|
||||
读取当前详情页搜索词。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:return: 搜索词
|
||||
"""
|
||||
search_context = snapshot.search
|
||||
return search_context.query if search_context is not None else ''
|
||||
|
||||
@staticmethod
|
||||
def resolve_action(*, active_view: str, slot: str, env: str) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前详情页指定槽位对应的动作。
|
||||
|
||||
:param active_view: 当前激活视图
|
||||
:param slot: 动作槽位
|
||||
:param env: 当前运行环境
|
||||
:return: 动作定义
|
||||
"""
|
||||
return TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key=active_view,
|
||||
slot=slot, # type: ignore[arg-type]
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
class DetailScreenFocusService(BaseScreenFocusService):
|
||||
"""
|
||||
详情页焦点服务。
|
||||
|
||||
该对象负责定义详情页可聚焦组件顺序,以及按左右键和滚动键解析
|
||||
当前目标区域。
|
||||
"""
|
||||
|
||||
def get_focus_order(self, screen: 'DetailScreen') -> list[WorkspaceSidebar | SectionNavigator | SectionDetailView]:
|
||||
"""
|
||||
定义详情页可通过左右键切换的焦点顺序。
|
||||
|
||||
:param screen: 当前详情页屏幕
|
||||
:return: 焦点顺序列表
|
||||
"""
|
||||
return [
|
||||
screen.query_one(WorkspaceSidebar),
|
||||
screen.query_one(SectionNavigator),
|
||||
screen.query_one(SectionDetailView),
|
||||
]
|
||||
|
||||
def get_default_scroll_target(
|
||||
self,
|
||||
screen: 'DetailScreen',
|
||||
) -> ScrollableContainer:
|
||||
"""
|
||||
返回详情页默认滚动容器。
|
||||
|
||||
:param screen: 当前详情页屏幕
|
||||
:return: 主工作区滚动容器
|
||||
"""
|
||||
return screen.query_one('#workspace-main', ScrollableContainer)
|
||||
|
||||
|
||||
class DetailScreen(ScreenInteractionActionsMixin, ScreenFocusActionsMixin, Screen[None]):
|
||||
"""
|
||||
TUI 通用详情屏幕。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('left', 'focus_left', TUI_COPY.build_internal_binding_label('focus_left'), show=False),
|
||||
Binding('right', 'focus_right', TUI_COPY.build_internal_binding_label('focus_right'), show=False),
|
||||
Binding('j', 'scroll_focus_down', TUI_COPY.build_internal_binding_label('scroll_down'), show=False),
|
||||
Binding('k', 'scroll_focus_up', TUI_COPY.build_internal_binding_label('scroll_up'), show=False),
|
||||
Binding('pagedown', 'scroll_focus_page_down', TUI_COPY.build_internal_binding_label('page_down'), show=False),
|
||||
Binding('pageup', 'scroll_focus_page_up', TUI_COPY.build_internal_binding_label('page_up'), show=False),
|
||||
Binding('home', 'scroll_focus_home', TUI_COPY.build_internal_binding_label('home'), show=False),
|
||||
Binding('end', 'scroll_focus_end', TUI_COPY.build_internal_binding_label('end'), show=False),
|
||||
Binding('x', 'trigger_primary_action', TUI_COPY.build_internal_binding_label('action_primary'), show=False),
|
||||
Binding('z', 'trigger_secondary_action', TUI_COPY.build_internal_binding_label('action_secondary'), show=False),
|
||||
Binding('y', 'trigger_global_action', TUI_COPY.build_internal_binding_label('action_global'), show=False),
|
||||
Binding('w', 'trigger_utility_action', TUI_COPY.build_internal_binding_label('action_utility'), show=False),
|
||||
Binding('/', 'open_search', TUI_COPY.build_internal_binding_label('search'), show=False),
|
||||
Binding('backspace', 'clear_search', TUI_COPY.build_internal_binding_label('clear_search'), show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: DetailPageSnapshot,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[NavigationItem],
|
||||
refreshed_at: str,
|
||||
support: DetailScreenSupport | None = None,
|
||||
focus_service: DetailScreenFocusService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化通用详情屏幕。
|
||||
|
||||
:param snapshot: 页面快照
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前激活视图
|
||||
:param navigation_items: 导航项列表
|
||||
:param refreshed_at: 本次刷新时间
|
||||
:param support: 详情页屏幕支持对象
|
||||
:param focus_service: 详情页焦点服务
|
||||
:return: None
|
||||
"""
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
self.selected_section_index = 0
|
||||
self._last_action_result: TuiActionResult | None = None
|
||||
self._action_feedback_lines: list[str] = []
|
||||
self._action_task: asyncio.Task[None] | None = None
|
||||
self.support = support or DetailScreenSupport()
|
||||
self.focus_service = focus_service or DetailScreenFocusService()
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def _cancel_task(task: asyncio.Task[None] | None) -> None:
|
||||
"""
|
||||
取消指定后台任务。
|
||||
|
||||
:param task: 待取消任务
|
||||
:return: None
|
||||
"""
|
||||
if task is None or task.done():
|
||||
return
|
||||
task.cancel()
|
||||
|
||||
def _get_section_or_fallback(self, index: int) -> DetailSectionSnapshot:
|
||||
"""
|
||||
获取指定索引的分区快照,缺失时返回兜底分区。
|
||||
|
||||
:param index: 分区索引
|
||||
:return: 分区快照
|
||||
"""
|
||||
if 0 <= index < len(self.snapshot.sections):
|
||||
return self.snapshot.sections[index]
|
||||
return self.support.build_empty_section()
|
||||
|
||||
def _build_summary_text(self) -> str:
|
||||
"""
|
||||
构建详情页摘要文本。
|
||||
|
||||
:return: 摘要文本
|
||||
"""
|
||||
return self.support.build_summary_text(self.snapshot, self.active_view)
|
||||
|
||||
def _current_search_query(self) -> str:
|
||||
return self.support.current_search_query(self.snapshot)
|
||||
|
||||
def _open_search(self) -> None:
|
||||
"""
|
||||
打开当前详情页搜索输入弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.open_search_dialog(
|
||||
self,
|
||||
self.snapshot.search,
|
||||
self._handle_search_submitted,
|
||||
)
|
||||
|
||||
def _handle_search_submitted(self, query: str | None) -> None:
|
||||
"""
|
||||
处理详情页搜索输入结果。
|
||||
|
||||
:param query: 搜索词
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.remember_query_and_refresh(self, self.active_view, query)
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
"""
|
||||
清空当前详情页搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
TUI_SCREEN_INTERACTION_SERVICE.clear_query_and_refresh(
|
||||
self,
|
||||
self.active_view,
|
||||
self._current_search_query(),
|
||||
)
|
||||
|
||||
def _resolve_action(self, slot: str) -> TuiActionSpec | None:
|
||||
"""
|
||||
解析当前详情页指定槽位对应的动作。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: 动作定义
|
||||
"""
|
||||
return self.support.resolve_action(active_view=self.active_view, slot=slot, env=self.env)
|
||||
|
||||
def _open_action_confirm(self, slot: str) -> None:
|
||||
"""
|
||||
打开指定槽位动作的确认弹窗。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: None
|
||||
"""
|
||||
self._action_task = TUI_SCREEN_INTERACTION_SERVICE.confirm_and_schedule_action(
|
||||
self,
|
||||
self._resolve_action(slot),
|
||||
self._action_task,
|
||||
self._execute_action,
|
||||
)
|
||||
|
||||
async def _execute_action(self, action: TuiActionSpec) -> None:
|
||||
"""
|
||||
执行详情页动作。
|
||||
|
||||
:param action: 动作定义
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _handle_result(result: TuiActionResult, feedback_lines: list[str]) -> None:
|
||||
self._last_action_result = result
|
||||
self._action_feedback_lines = feedback_lines
|
||||
|
||||
await TUI_SCREEN_INTERACTION_SERVICE.execute_action_with_feedback(
|
||||
self,
|
||||
action,
|
||||
self.env,
|
||||
self.active_view,
|
||||
_handle_result,
|
||||
)
|
||||
|
||||
def _update_selected_section(self, index: int) -> None:
|
||||
"""
|
||||
更新当前选中分区,并同步右侧详情视图。
|
||||
|
||||
:param index: 待选中的分区索引
|
||||
:return: None
|
||||
"""
|
||||
if not self.snapshot.sections:
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
detail_view.show_section(self._get_section_or_fallback(index), query=self._current_search_query())
|
||||
return
|
||||
if not 0 <= index < len(self.snapshot.sections):
|
||||
return
|
||||
self.selected_section_index = index
|
||||
detail_view = self.query_one(SectionDetailView)
|
||||
detail_view.show_section(self.snapshot.sections[index], query=self._current_search_query())
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""
|
||||
构建详情页界面结构。
|
||||
|
||||
:return: Textual 组件结果
|
||||
"""
|
||||
yield WorkspaceHeader(self.env, self.active_view)
|
||||
with Horizontal(id='workspace-shell'):
|
||||
yield WorkspaceSidebar(self.env, self.navigation_items, self.active_view)
|
||||
with ScrollableContainer(id='workspace-main'):
|
||||
yield WorkspaceHero(
|
||||
title=self.snapshot.title,
|
||||
subtitle=self.snapshot.subtitle,
|
||||
env=self.env,
|
||||
active_view=self.active_view,
|
||||
summary=self._build_summary_text(),
|
||||
refreshed_at=self.refreshed_at,
|
||||
)
|
||||
with Horizontal(id='detail-body'):
|
||||
yield SectionNavigator(
|
||||
self.snapshot.sections,
|
||||
initial_index=self.selected_section_index,
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield SectionDetailView(
|
||||
self._get_section_or_fallback(self.selected_section_index),
|
||||
query=self._current_search_query(),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""
|
||||
详情页挂载后默认将焦点停留在左侧导航。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.call_after_refresh(self.query_one(WorkspaceSidebar).focus)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
"""
|
||||
详情页卸载时取消尚未完成的后台任务。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._cancel_task(self._action_task)
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧边栏事件打开对应视图,并避免重复打开当前页面。
|
||||
|
||||
:param event: 侧边栏高亮或选中事件
|
||||
:return: None
|
||||
"""
|
||||
item = getattr(event.item, 'item', None)
|
||||
if not isinstance(item, NavigationItem):
|
||||
return
|
||||
if item.view_key == self.active_view:
|
||||
return
|
||||
self.app.open_view(item.view_key)
|
||||
|
||||
@on(WorkspaceSidebar.Selected)
|
||||
def on_sidebar_selected(self, event: WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
响应左侧导航选中事件并切换视图。
|
||||
|
||||
:param event: 导航选中事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(WorkspaceSidebar.Highlighted)
|
||||
def on_sidebar_highlighted(self, event: WorkspaceSidebar.Highlighted) -> None:
|
||||
"""
|
||||
响应左侧导航高亮变更并立即切换视图。
|
||||
|
||||
:param event: 导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._open_sidebar_item(event)
|
||||
|
||||
@on(SectionNavigator.Changed)
|
||||
def on_section_changed(self, event: SectionNavigator.Changed) -> None:
|
||||
"""
|
||||
响应分区导航高亮变更并更新详情内容。
|
||||
|
||||
:param event: 分区导航高亮事件
|
||||
:return: None
|
||||
"""
|
||||
self._update_selected_section(event.index)
|
||||
@@ -0,0 +1,152 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from textual.containers import ScrollableContainer
|
||||
|
||||
|
||||
class BaseScreenFocusService(ABC):
|
||||
"""
|
||||
TUI 屏幕焦点服务基类。
|
||||
|
||||
该基类统一处理焦点顺序中的左右移动和滚动目标回退逻辑,具体页面
|
||||
只需声明自身的焦点顺序和默认滚动容器。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_focus_order(self, screen: Any) -> list[Any]:
|
||||
"""
|
||||
返回当前页面的焦点顺序。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:return: 焦点组件列表
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_default_scroll_target(self, screen: Any) -> ScrollableContainer:
|
||||
"""
|
||||
返回当前页面的默认滚动容器。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:return: 默认滚动容器
|
||||
"""
|
||||
|
||||
def move_focus(self, screen: Any, step: int) -> None:
|
||||
"""
|
||||
按给定方向移动当前焦点。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param step: 焦点移动步长
|
||||
:return: None
|
||||
"""
|
||||
order = self.get_focus_order(screen)
|
||||
focused = screen.app.focused
|
||||
if focused not in order:
|
||||
order[0].focus()
|
||||
return
|
||||
target_index = max(0, min(order.index(focused) + step, len(order) - 1))
|
||||
order[target_index].focus()
|
||||
|
||||
def get_scroll_target(self, screen: Any) -> Any:
|
||||
"""
|
||||
获取当前应响应滚动动作的焦点区域。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:return: 当前焦点组件或默认滚动容器
|
||||
"""
|
||||
focused = screen.app.focused
|
||||
order = self.get_focus_order(screen)
|
||||
if focused in order:
|
||||
return focused
|
||||
return self.get_default_scroll_target(screen)
|
||||
|
||||
|
||||
class ScreenFocusActionsMixin:
|
||||
"""
|
||||
TUI 屏幕焦点动作混入。
|
||||
|
||||
该混入负责将焦点服务暴露为屏幕上的左右移动与滚动 action,避免
|
||||
各个 Screen 类重复编写同形转发方法。
|
||||
"""
|
||||
|
||||
focus_service: BaseScreenFocusService
|
||||
|
||||
def _move_focus(self, step: int) -> None:
|
||||
"""
|
||||
按给定方向移动当前焦点。
|
||||
|
||||
:param step: 焦点移动步长
|
||||
:return: None
|
||||
"""
|
||||
self.focus_service.move_focus(self, step)
|
||||
|
||||
def _get_scroll_target(self) -> Any:
|
||||
"""
|
||||
获取当前应响应滚动动作的焦点区域。
|
||||
|
||||
:return: 当前焦点组件或主工作区
|
||||
"""
|
||||
return self.focus_service.get_scroll_target(self)
|
||||
|
||||
def action_scroll_focus_down(self) -> None:
|
||||
"""
|
||||
向下滚动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_down(animate=False)
|
||||
|
||||
def action_scroll_focus_up(self) -> None:
|
||||
"""
|
||||
向上滚动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_up(animate=False)
|
||||
|
||||
def action_scroll_focus_page_down(self) -> None:
|
||||
"""
|
||||
向下翻动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_page_down(animate=False)
|
||||
|
||||
def action_scroll_focus_page_up(self) -> None:
|
||||
"""
|
||||
向上翻动当前焦点区域。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_page_up(animate=False)
|
||||
|
||||
def action_scroll_focus_home(self) -> None:
|
||||
"""
|
||||
将当前焦点区域滚动到起始位置。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_home(animate=False)
|
||||
|
||||
def action_scroll_focus_end(self) -> None:
|
||||
"""
|
||||
将当前焦点区域滚动到末尾位置。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._get_scroll_target().scroll_end(animate=False)
|
||||
|
||||
def action_focus_left(self) -> None:
|
||||
"""
|
||||
将焦点向左移动一列。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._move_focus(-1)
|
||||
|
||||
def action_focus_right(self) -> None:
|
||||
"""
|
||||
将焦点向右移动一列。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._move_focus(1)
|
||||
@@ -0,0 +1,331 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from textual.app import SuspendNotSupported
|
||||
|
||||
from cli.tui.actions import (
|
||||
TUI_ACTION_EXECUTION_SERVICE,
|
||||
TUI_ACTION_PRESENTATION_SERVICE,
|
||||
TuiActionResult,
|
||||
TuiActionSpec,
|
||||
)
|
||||
from cli.tui.copy import TUI_COPY
|
||||
from cli.tui.screens.confirm import ActionConfirmScreen
|
||||
from cli.tui.screens.search import SearchInputScreen
|
||||
from cli.tui.search import PageSearchContext
|
||||
from cli.tui.widgets import WorkspaceSidebar
|
||||
|
||||
|
||||
class TuiScreenInteractionService:
|
||||
"""
|
||||
TUI 屏幕交互服务。
|
||||
|
||||
该服务负责搜索弹窗、搜索状态写回、动作确认弹窗和动作执行通知等
|
||||
屏幕级交互流程,减少浏览页与详情页之间的重复控制逻辑。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def open_search_dialog(
|
||||
screen: Any,
|
||||
search_context: PageSearchContext | None,
|
||||
callback: Callable[[str | None], None],
|
||||
) -> None:
|
||||
"""
|
||||
打开搜索输入弹窗。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param search_context: 搜索上下文
|
||||
:param callback: 提交搜索词后的回调
|
||||
:return: None
|
||||
"""
|
||||
if search_context is None:
|
||||
return
|
||||
screen.app.push_screen(
|
||||
SearchInputScreen(
|
||||
'页内搜索',
|
||||
search_context.placeholder,
|
||||
search_context.query,
|
||||
search_context.suggestions,
|
||||
),
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remember_query_and_refresh(screen: Any, active_view: str, query: str | None) -> None:
|
||||
"""
|
||||
记录当前页面搜索词并刷新页面。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param active_view: 当前页面视图标识
|
||||
:param query: 搜索词
|
||||
:return: None
|
||||
"""
|
||||
if query is None:
|
||||
return
|
||||
remember_query = getattr(screen.app, 'remember_browser_query', None)
|
||||
if callable(remember_query):
|
||||
remember_query(active_view, query)
|
||||
screen.app.action_refresh_current_view()
|
||||
|
||||
def clear_query_and_refresh(self, screen: Any, active_view: str, current_query: str) -> None:
|
||||
"""
|
||||
清空当前页面搜索词并刷新页面。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param active_view: 当前页面视图标识
|
||||
:param current_query: 当前搜索词
|
||||
:return: None
|
||||
"""
|
||||
if not current_query:
|
||||
return
|
||||
self.remember_query_and_refresh(screen, active_view, '')
|
||||
|
||||
@staticmethod
|
||||
def open_action_confirm(
|
||||
screen: Any,
|
||||
action: TuiActionSpec | None,
|
||||
callback: Callable[[bool | None, TuiActionSpec], None],
|
||||
) -> None:
|
||||
"""
|
||||
打开动作确认弹窗。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 待执行动作
|
||||
:param callback: 确认回调
|
||||
:return: None
|
||||
"""
|
||||
if action is None:
|
||||
screen.notify(
|
||||
TUI_COPY.build_action_unavailable_message(),
|
||||
title=TUI_COPY.build_action_notification_title(),
|
||||
severity='warning',
|
||||
)
|
||||
return
|
||||
screen.app.push_screen(
|
||||
ActionConfirmScreen(action.preview_title, action.preview_lines, action.label),
|
||||
callback=lambda confirmed, action=action: callback(confirmed, action),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def schedule_action_task(
|
||||
existing_task: asyncio.Task[None] | None,
|
||||
action: TuiActionSpec,
|
||||
execute_callback: Callable[[TuiActionSpec], Awaitable[None]],
|
||||
) -> asyncio.Task[None]:
|
||||
"""
|
||||
安排动作执行任务,并在必要时取消旧任务。
|
||||
|
||||
:param existing_task: 现有动作任务
|
||||
:param action: 待执行动作
|
||||
:param execute_callback: 动作执行协程工厂
|
||||
:return: 新建任务
|
||||
"""
|
||||
if existing_task is not None and not existing_task.done():
|
||||
existing_task.cancel()
|
||||
return asyncio.create_task(execute_callback(action))
|
||||
|
||||
def confirm_and_schedule_action(
|
||||
self,
|
||||
screen: Any,
|
||||
action: TuiActionSpec | None,
|
||||
existing_task: asyncio.Task[None] | None,
|
||||
execute_callback: Callable[[TuiActionSpec], Awaitable[None]],
|
||||
) -> asyncio.Task[None] | None:
|
||||
"""
|
||||
统一处理动作确认结果,并在确认后调度后台执行任务。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 待执行动作
|
||||
:param existing_task: 当前已有动作任务
|
||||
:param execute_callback: 动作执行协程工厂
|
||||
:return: 新建任务或原任务
|
||||
"""
|
||||
if action is None:
|
||||
self.open_action_confirm(screen, action, lambda confirmed, action_spec: None)
|
||||
return existing_task
|
||||
|
||||
scheduled_task = existing_task
|
||||
|
||||
def _handle_confirmed(confirmed: bool | None, action_spec: TuiActionSpec) -> None:
|
||||
nonlocal scheduled_task
|
||||
if not confirmed:
|
||||
return
|
||||
scheduled_task = self.schedule_action_task(
|
||||
scheduled_task,
|
||||
action_spec,
|
||||
execute_callback,
|
||||
)
|
||||
|
||||
self.open_action_confirm(screen, action, _handle_confirmed)
|
||||
return scheduled_task
|
||||
|
||||
async def execute_action(
|
||||
self,
|
||||
screen: Any,
|
||||
action: TuiActionSpec,
|
||||
env: str,
|
||||
active_view: str,
|
||||
*,
|
||||
on_result: Callable[[TuiActionResult, list[str]], Any] | None = None,
|
||||
) -> TuiActionResult:
|
||||
"""
|
||||
执行屏幕动作并统一处理通知、反馈持久化与刷新。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 动作定义
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前页面视图标识
|
||||
:param on_result: 动作结果回调
|
||||
:return: 动作结果
|
||||
"""
|
||||
screen.notify(
|
||||
TUI_COPY.build_action_running_message(action.label),
|
||||
title=TUI_COPY.build_action_notification_title(),
|
||||
)
|
||||
if action.execution_mode == 'external':
|
||||
try:
|
||||
with screen.app.suspend():
|
||||
result = TUI_ACTION_EXECUTION_SERVICE.execute_external(action)
|
||||
except SuspendNotSupported:
|
||||
result = TuiActionResult(
|
||||
spec=action,
|
||||
external_exit_code=1,
|
||||
external_message='当前终端不支持挂起 TUI,无法打开外部交互向导',
|
||||
)
|
||||
else:
|
||||
result = await asyncio.to_thread(TUI_ACTION_EXECUTION_SERVICE.execute, action, env)
|
||||
|
||||
feedback_lines = TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result)
|
||||
remember_feedback = getattr(screen.app, 'remember_action_feedback', None)
|
||||
if callable(remember_feedback):
|
||||
remember_feedback(active_view, feedback_lines)
|
||||
if on_result is not None:
|
||||
maybe_result = on_result(result, feedback_lines)
|
||||
if inspect.isawaitable(maybe_result):
|
||||
await maybe_result
|
||||
screen.notify(
|
||||
TUI_ACTION_PRESENTATION_SERVICE.build_action_result_message(result),
|
||||
title=TUI_COPY.build_action_notification_title(),
|
||||
severity='information' if result.ok else 'error',
|
||||
)
|
||||
if result.ok and action.refresh_view:
|
||||
screen.app.action_refresh_current_view()
|
||||
return result
|
||||
|
||||
async def execute_action_with_feedback(
|
||||
self,
|
||||
screen: Any,
|
||||
action: TuiActionSpec,
|
||||
env: str,
|
||||
active_view: str,
|
||||
feedback_callback: Callable[[TuiActionResult, list[str]], Any],
|
||||
) -> TuiActionResult:
|
||||
"""
|
||||
执行动作并将统一反馈回调注入结果收口流程。
|
||||
|
||||
:param screen: 当前屏幕对象
|
||||
:param action: 动作定义
|
||||
:param env: 当前运行环境
|
||||
:param active_view: 当前页面视图标识
|
||||
:param feedback_callback: 反馈写回回调
|
||||
:return: 动作结果
|
||||
"""
|
||||
return await self.execute_action(
|
||||
screen,
|
||||
action,
|
||||
env,
|
||||
active_view,
|
||||
on_result=feedback_callback,
|
||||
)
|
||||
|
||||
|
||||
class ScreenInteractionActionsMixin:
|
||||
"""
|
||||
屏幕交互动作混入。
|
||||
|
||||
该混入负责把搜索、动作确认和侧栏切屏这类重复桥接 action 收口为
|
||||
通用 Screen 方法,具体页面只需实现对应的私有协作方法。
|
||||
"""
|
||||
|
||||
def _open_search(self) -> None:
|
||||
"""
|
||||
打开当前页面搜索输入弹窗。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _clear_search(self) -> None:
|
||||
"""
|
||||
清空当前页面搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _open_action_confirm(self, slot: str) -> None:
|
||||
"""
|
||||
打开指定槽位动作的确认弹窗。
|
||||
|
||||
:param slot: 动作槽位
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _open_sidebar_item(self, event: WorkspaceSidebar.Highlighted | WorkspaceSidebar.Selected) -> None:
|
||||
"""
|
||||
根据侧栏事件打开对应视图。
|
||||
|
||||
:param event: 侧栏事件
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def action_open_search(self) -> None:
|
||||
"""
|
||||
打开当前页面搜索输入。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_search()
|
||||
|
||||
def action_clear_search(self) -> None:
|
||||
"""
|
||||
清空当前页面搜索词。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._clear_search()
|
||||
|
||||
def action_trigger_primary_action(self) -> None:
|
||||
"""
|
||||
触发当前页面主动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('primary')
|
||||
|
||||
def action_trigger_secondary_action(self) -> None:
|
||||
"""
|
||||
触发当前页面次动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('secondary')
|
||||
|
||||
def action_trigger_global_action(self) -> None:
|
||||
"""
|
||||
触发当前页面全局动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('global')
|
||||
|
||||
def action_trigger_utility_action(self) -> None:
|
||||
"""
|
||||
触发当前页面工具动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self._open_action_confirm('utility')
|
||||
|
||||
|
||||
TUI_SCREEN_INTERACTION_SERVICE = TuiScreenInteractionService()
|
||||
@@ -0,0 +1,80 @@
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Static
|
||||
|
||||
from cli.tui.copy import TUI_COPY
|
||||
|
||||
|
||||
class SearchInputScreen(ModalScreen[str | None]):
|
||||
"""
|
||||
通用搜索输入弹窗。
|
||||
|
||||
:param title: 弹窗标题
|
||||
:param placeholder: 输入框提示
|
||||
:param value: 当前搜索词
|
||||
:param suggestions: 候选建议
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
SearchInputScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#search-dialog {
|
||||
width: 88;
|
||||
max-width: 88;
|
||||
border: heavy #38d8ff;
|
||||
background: #06111b;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#search-title {
|
||||
text-style: bold;
|
||||
color: #e9fcff;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#search-suggestions {
|
||||
color: #8fc6d6;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding('escape', 'cancel', TUI_COPY.build_confirm_binding_label('cancel'), show=False),
|
||||
Binding('enter', 'submit', TUI_COPY.build_confirm_binding_label('submit'), show=False),
|
||||
]
|
||||
|
||||
def __init__(self, title: str, placeholder: str, value: str, suggestions: list[str]) -> None:
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._placeholder = placeholder
|
||||
self._value = value
|
||||
self._suggestions = suggestions
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id='search-dialog'):
|
||||
yield Static(self._title, id='search-title', markup=False)
|
||||
yield Input(value=self._value, placeholder=self._placeholder, id='search-input')
|
||||
suggestion_text = '候选建议 · ' + (' '.join(self._suggestions[:6]) if self._suggestions else '暂无建议')
|
||||
yield Static(suggestion_text, id='search-suggestions', markup=False)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.call_after_refresh(self.query_one('#search-input', Input).focus)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
input_widget = self.query_one('#search-input', Input)
|
||||
self.dismiss(str(input_widget.value or '').strip())
|
||||
|
||||
@on(Input.Submitted, '#search-input')
|
||||
def on_input_submitted(self) -> None:
|
||||
self.action_submit()
|
||||
@@ -0,0 +1,59 @@
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class StatusCarrier(Protocol):
|
||||
"""
|
||||
状态载体协议。
|
||||
|
||||
该协议用于描述首页面板、详情分区和浏览记录这类具备 `status`
|
||||
属性的摘要统计对象。
|
||||
"""
|
||||
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusSummary:
|
||||
"""
|
||||
状态摘要统计结果。
|
||||
|
||||
:param total_count: 总数量
|
||||
:param ok_count: 正常数量
|
||||
:param warn_count: 警告数量
|
||||
:param fail_count: 失败数量
|
||||
"""
|
||||
|
||||
total_count: int
|
||||
ok_count: int
|
||||
warn_count: int
|
||||
fail_count: int
|
||||
|
||||
|
||||
class StatusSummaryBuilder:
|
||||
"""
|
||||
状态摘要构建器。
|
||||
|
||||
该对象负责对带 `status` 属性的集合做统一计数,供 dashboard、
|
||||
detail、browser 三类 screen support 复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build(items: Iterable[StatusCarrier]) -> StatusSummary:
|
||||
"""
|
||||
统计状态摘要。
|
||||
|
||||
:param items: 待统计对象集合
|
||||
:return: 状态摘要
|
||||
"""
|
||||
materialized = list(items)
|
||||
return StatusSummary(
|
||||
total_count=len(materialized),
|
||||
ok_count=sum(1 for item in materialized if item.status == 'ok'),
|
||||
warn_count=sum(1 for item in materialized if item.status == 'warn'),
|
||||
fail_count=sum(1 for item in materialized if item.status == 'fail'),
|
||||
)
|
||||
|
||||
|
||||
STATUS_SUMMARY_BUILDER = StatusSummaryBuilder()
|
||||
Reference in New Issue
Block a user