初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -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()