1263 lines
37 KiB
Python
1263 lines
37 KiB
Python
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from math import sin
|
|
from time import monotonic
|
|
|
|
from rich.text import Text
|
|
from textual.message import Message
|
|
from textual.widgets import Label, ListItem, ListView, Static
|
|
|
|
from cli.tui.adapters.models import BrowserRecordSnapshot, DetailSectionSnapshot
|
|
from cli.tui.copy import TUI_COPY
|
|
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
|
|
from cli.tui.search import TUI_SEARCH_HIGHLIGHTER
|
|
|
|
|
|
class WorkspaceRenderingSupport:
|
|
"""
|
|
工作台组件渲染支持对象。
|
|
|
|
该对象负责状态徽标、状态类名、结构化文本渲染和预览提取,
|
|
供工作台中的导航项、详情视图和摘要视图复用。
|
|
"""
|
|
|
|
@staticmethod
|
|
def highlight_search_text(text: str, query: str) -> str:
|
|
"""
|
|
对工作台文本执行搜索高亮。
|
|
|
|
:param text: 原始文本
|
|
:param query: 搜索词
|
|
:return: 高亮后的文本
|
|
"""
|
|
return TUI_SEARCH_HIGHLIGHTER.highlight(text, query)
|
|
|
|
@staticmethod
|
|
def render_status_badge(status: str) -> str:
|
|
"""
|
|
将状态值转换为紧凑徽标文本。
|
|
|
|
:param status: 原始状态值
|
|
:return: 徽标文本
|
|
"""
|
|
return f'[{TUI_COPY.render_status_code(status)}]'
|
|
|
|
@staticmethod
|
|
def resolve_status_class(status: str) -> str:
|
|
"""
|
|
根据状态值解析统一样式类。
|
|
|
|
:param status: 原始状态值
|
|
:return: 状态样式类名
|
|
"""
|
|
normalized_status = str(status).strip().lower()
|
|
if normalized_status in {'ok', 'success', 'healthy'}:
|
|
return 'is-ok'
|
|
if normalized_status in {'fail', 'error'}:
|
|
return 'is-fail'
|
|
if normalized_status in {'warn', 'warning'}:
|
|
return 'is-warn'
|
|
return 'is-info'
|
|
|
|
@staticmethod
|
|
def strip_line_markup(line: str) -> str:
|
|
"""
|
|
去除内部展示标记,便于提取预览文本。
|
|
|
|
:param line: 原始文本行
|
|
:return: 纯文本内容
|
|
"""
|
|
stripped = line.strip()
|
|
if stripped.startswith('## '):
|
|
return stripped[3:].strip()
|
|
if stripped.startswith('> '):
|
|
return stripped[2:].strip()
|
|
return stripped
|
|
|
|
def build_preview_line(self, lines: list[str], fallback: str) -> str:
|
|
"""
|
|
从结构化文本中提取首条适合作为预览的内容。
|
|
|
|
:param lines: 原始文本行列表
|
|
:param fallback: 兜底文本
|
|
:return: 预览文本
|
|
"""
|
|
for line in lines:
|
|
preview = self.strip_line_markup(line)
|
|
if preview:
|
|
return preview
|
|
return fallback
|
|
|
|
def render_structured_lines(self, lines: list[str], empty_text: str) -> str:
|
|
"""
|
|
将结构化文本行渲染为适合终端展示的块文本。
|
|
|
|
支持以下轻量约定:
|
|
- `## ` 开头:区块标题
|
|
- `> ` 开头:说明或子项
|
|
- 空字符串:保留空行
|
|
|
|
:param lines: 原始文本行列表
|
|
:param empty_text: 空态提示文本
|
|
:return: 渲染后的文本块
|
|
"""
|
|
rendered_lines: list[str] = []
|
|
for raw_line in lines:
|
|
stripped = raw_line.strip()
|
|
if not stripped:
|
|
if rendered_lines and rendered_lines[-1] != '':
|
|
rendered_lines.append('')
|
|
continue
|
|
if stripped.startswith('## '):
|
|
title = stripped[3:].strip()
|
|
if rendered_lines and rendered_lines[-1] != '':
|
|
rendered_lines.append('')
|
|
rendered_lines.append(f'【{title}】')
|
|
rendered_lines.append('─' * max(10, min(len(title) * 2, 30)))
|
|
continue
|
|
if stripped.startswith('> '):
|
|
rendered_lines.append(f'│ {stripped[2:].strip()}')
|
|
continue
|
|
rendered_lines.append(f'• {stripped}')
|
|
|
|
while rendered_lines and rendered_lines[0] == '':
|
|
rendered_lines.pop(0)
|
|
while rendered_lines and rendered_lines[-1] == '':
|
|
rendered_lines.pop()
|
|
|
|
if not rendered_lines:
|
|
return f'- {empty_text}'
|
|
return '\n'.join(rendered_lines)
|
|
|
|
|
|
WORKSPACE_RENDERING = WorkspaceRenderingSupport()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NavigationItem:
|
|
"""
|
|
工作台导航项定义。
|
|
|
|
:param view_key: 视图唯一标识
|
|
:param label: 导航显示名称
|
|
:param shortcut: 快捷键
|
|
:param description: 导航说明
|
|
"""
|
|
|
|
view_key: str
|
|
label: str
|
|
shortcut: str
|
|
description: str
|
|
|
|
|
|
class NavigationListItem(ListItem):
|
|
"""
|
|
工作台导航列表项。
|
|
|
|
:param item: 导航项定义
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
NavigationListItem {
|
|
padding: 0 1;
|
|
margin-bottom: 1;
|
|
border: round #18425d;
|
|
background: #07131f;
|
|
color: #89b9c9;
|
|
height: auto;
|
|
}
|
|
|
|
NavigationListItem Label {
|
|
width: 1fr;
|
|
}
|
|
|
|
NavigationListItem.is-active {
|
|
border: round #38d8ff;
|
|
background: #0a1e2d;
|
|
color: #e4fbff;
|
|
}
|
|
|
|
NavigationListItem.is-active Label {
|
|
text-style: bold;
|
|
}
|
|
|
|
NavigationListItem.-highlight {
|
|
border: heavy #6df7ff;
|
|
background: #10364b;
|
|
color: #ffffff;
|
|
text-style: bold;
|
|
padding-left: 2;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, item: NavigationItem, index: int, *, active: bool = False) -> None:
|
|
"""
|
|
初始化导航列表项。
|
|
|
|
:param item: 导航项定义
|
|
:return: None
|
|
"""
|
|
self.item = item
|
|
super().__init__(
|
|
Label(
|
|
'\n'.join(TUI_COPY.build_navigation_item_lines(index, item.label, item.shortcut, item.description)),
|
|
markup=False,
|
|
)
|
|
)
|
|
if active:
|
|
self.add_class('is-active')
|
|
|
|
|
|
class WorkspaceSidebar(ListView):
|
|
"""
|
|
工作台左侧导航组件。
|
|
|
|
:param env: 当前运行环境
|
|
:param items: 导航项列表
|
|
:param active_view: 当前激活视图
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
WorkspaceSidebar {
|
|
width: 34;
|
|
min-width: 34;
|
|
padding: 1;
|
|
margin: 1 0 1 1;
|
|
border: double #18425d;
|
|
background: #06111b;
|
|
}
|
|
|
|
WorkspaceSidebar:focus {
|
|
border: heavy #6df7ff;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, env: str, items: list[NavigationItem], active_view: str) -> None:
|
|
"""
|
|
初始化工作台导航组件。
|
|
|
|
:param env: 当前运行环境
|
|
:param items: 导航项列表
|
|
:param active_view: 当前激活视图
|
|
:return: None
|
|
"""
|
|
self.env = env
|
|
self.items = items
|
|
self.active_view = active_view
|
|
initial_index = self._resolve_initial_index()
|
|
children = [
|
|
NavigationListItem(item, index, active=item.view_key == self.active_view)
|
|
for index, item in enumerate(self.items)
|
|
]
|
|
super().__init__(*children, initial_index=initial_index, id='workspace-sidebar')
|
|
|
|
def _resolve_initial_index(self) -> int:
|
|
"""
|
|
解析当前激活视图对应的导航索引。
|
|
|
|
:return: 初始高亮索引
|
|
"""
|
|
for index, item in enumerate(self.items):
|
|
if item.view_key == self.active_view:
|
|
return index
|
|
return 0
|
|
|
|
|
|
class WorkspaceHero(Static):
|
|
"""
|
|
工作台顶部摘要组件。
|
|
|
|
:param title: 页面标题
|
|
:param subtitle: 页面副标题
|
|
:param env: 当前运行环境
|
|
:param active_view: 当前激活视图
|
|
:param summary: 状态摘要文本
|
|
:param refreshed_at: 本次刷新时间
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
WorkspaceHero {
|
|
border: double #38d8ff;
|
|
padding: 1 2;
|
|
margin-bottom: 1;
|
|
min-height: 11;
|
|
background: #081827;
|
|
color: #e9fcff;
|
|
}
|
|
"""
|
|
_BORDER_PHASES = ('#2fb8df', '#38d8ff', '#62ecff', '#38d8ff')
|
|
_TITLE_GLOW_PALETTE = ('#7be8ff', '#b4f6ff', '#e9fdff', '#b4f6ff')
|
|
|
|
def __init__(
|
|
self,
|
|
title: str,
|
|
subtitle: str,
|
|
env: str,
|
|
active_view: str,
|
|
summary: str,
|
|
refreshed_at: str,
|
|
) -> None:
|
|
"""
|
|
初始化工作台顶部摘要组件。
|
|
|
|
:param title: 页面标题
|
|
:param subtitle: 页面副标题
|
|
:param active_view: 当前激活视图
|
|
:param summary: 状态摘要文本
|
|
:param refreshed_at: 本次刷新时间
|
|
:return: None
|
|
"""
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.env = env
|
|
self.active_view = active_view
|
|
self.summary = summary
|
|
self.refreshed_at = refreshed_at
|
|
super().__init__(self._build_render_text(), id='workspace-hero', markup=False)
|
|
|
|
def on_mount(self) -> None:
|
|
"""
|
|
在组件挂载后启动低频边框呼吸动效。
|
|
|
|
:return: None
|
|
"""
|
|
self.set_interval(0.9, self._pulse_border, name='pulse workspace hero border')
|
|
|
|
def _pulse_border(self) -> None:
|
|
"""
|
|
以低频节奏切换 Hero 边框颜色。
|
|
|
|
:return: None
|
|
"""
|
|
phase_index = int(monotonic() * 1.1) % len(self._BORDER_PHASES)
|
|
self.styles.border = ('double', self._BORDER_PHASES[phase_index])
|
|
|
|
@classmethod
|
|
def _build_title_text(cls, title: str) -> Text:
|
|
"""
|
|
构建 Hero 标题渐变文本。
|
|
|
|
:param title: 标题文本
|
|
:return: 富文本标题
|
|
"""
|
|
rendered = Text()
|
|
palette_size = len(cls._TITLE_GLOW_PALETTE)
|
|
for index, char in enumerate(title):
|
|
rendered.append(char, style=f'bold {cls._TITLE_GLOW_PALETTE[index % palette_size]}')
|
|
return rendered
|
|
|
|
def _build_render_text(self) -> Text:
|
|
"""
|
|
构建顶部摘要文本。
|
|
|
|
:return: 渲染富文本
|
|
"""
|
|
lines = TUI_COPY.build_workspace_hero_lines(
|
|
view_label=TUI_COPY.render_view_label(self.active_view),
|
|
title=self.title,
|
|
subtitle=self.subtitle,
|
|
env=self.env,
|
|
summary=self.summary,
|
|
refreshed_at=self.refreshed_at,
|
|
shortcut_hint=TUI_KEYMAP_REGISTRY.hero_shortcut_hint,
|
|
)
|
|
rendered = Text()
|
|
for index, line in enumerate(lines):
|
|
if index:
|
|
rendered.append('\n')
|
|
if line.startswith('标题 · '):
|
|
rendered.append('标题 · ', style='bold #8fd9e8')
|
|
rendered.append_text(self._build_title_text(line.removeprefix('标题 · ')))
|
|
continue
|
|
rendered.append(line, style='bold #e9fcff' if index == 0 else '#d7f7ff')
|
|
return rendered
|
|
|
|
|
|
class WorkspaceHeader(Static):
|
|
"""
|
|
工作台顶部状态栏。
|
|
|
|
:param env: 当前运行环境
|
|
:param active_view: 当前激活视图
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
WorkspaceHeader {
|
|
height: 2;
|
|
padding: 0 2;
|
|
background: #060d16;
|
|
color: #c7f6ff;
|
|
border-bottom: heavy #163c57;
|
|
content-align: left middle;
|
|
text-style: bold;
|
|
}
|
|
"""
|
|
_HEADER_LINE_COUNT = 2
|
|
_SCANLINE_WIDTH = 16
|
|
_HEADER_HORIZONTAL_PADDING = 4
|
|
_HEADER_FALLBACK_WIDTH = 108
|
|
_SCANLINE_BASE_STYLE = '#1c5368'
|
|
_SCANLINE_GLOW_STYLES = {
|
|
'·': '#8ef7ff',
|
|
'░': '#62ecff',
|
|
'▒': '#4ee4ff',
|
|
'▓': '#2cc9ff',
|
|
'█': '#dffcff',
|
|
}
|
|
|
|
@classmethod
|
|
def build_scanline_text(cls, phase: float, width: int) -> Text:
|
|
"""
|
|
构建页眉扫描线文本。
|
|
|
|
通过单个高亮扫描头和两侧衰减尾迹,营造简洁的霓虹扫线效果。
|
|
|
|
:param phase: 当前动画相位
|
|
:param width: 扫描线宽度
|
|
:return: 带颜色层次的扫描线文本
|
|
"""
|
|
cells = ['─'] * width
|
|
head_position = int(((sin(phase * 0.82) + 1) / 2) * (width - 1))
|
|
for offset, symbol in ((-2, '·'), (-1, '░'), (0, '█'), (1, '▓'), (2, '▒')):
|
|
position = head_position + offset
|
|
if 0 <= position < width:
|
|
cells[position] = symbol
|
|
rendered = Text()
|
|
for symbol in cells:
|
|
rendered.append(symbol, style=cls._SCANLINE_GLOW_STYLES.get(symbol, cls._SCANLINE_BASE_STYLE))
|
|
return rendered
|
|
|
|
def __init__(self, env: str, active_view: str) -> None:
|
|
"""
|
|
初始化工作台顶部状态栏。
|
|
|
|
:param env: 当前运行环境
|
|
:param active_view: 当前激活视图
|
|
:return: None
|
|
"""
|
|
self.env = env
|
|
self.active_view = active_view
|
|
super().__init__(id='workspace-header', markup=False)
|
|
|
|
def on_mount(self) -> None:
|
|
"""
|
|
在组件挂载后启动时钟刷新。
|
|
|
|
:return: None
|
|
"""
|
|
self.set_interval(0.18, self.refresh, name='update workspace header')
|
|
|
|
def build_scanline_text_label(self) -> Text:
|
|
"""
|
|
构建扫描线文本。
|
|
|
|
:return: 扫描线文本
|
|
"""
|
|
phase = monotonic() * 2.1
|
|
return self.build_scanline_text(phase, self._SCANLINE_WIDTH)
|
|
|
|
def build_symmetric_effects(self) -> tuple[Text, Text]:
|
|
"""
|
|
构建标题两侧的对称动效文本。
|
|
|
|
左右两侧都使用固定宽度,确保标题中心点稳定,不随动效变化漂移。
|
|
|
|
:return: 左右动效文本
|
|
"""
|
|
left_effect = self.build_scanline_text_label()
|
|
right_effect = Text()
|
|
for span_symbol in reversed(left_effect.plain):
|
|
right_effect.append(
|
|
span_symbol,
|
|
style=self._SCANLINE_GLOW_STYLES.get(span_symbol, self._SCANLINE_BASE_STYLE),
|
|
)
|
|
return left_effect, right_effect
|
|
|
|
def build_centered_title_line(self, title_text: str) -> Text:
|
|
"""
|
|
构建居中的标题装饰行。
|
|
|
|
标题左右分别放置镜像扫描线动效,中间保留标题主体。
|
|
|
|
:param title_text: 标题主体文本
|
|
:return: 居中后的富文本标题行
|
|
"""
|
|
left_effect, right_effect = self.build_symmetric_effects()
|
|
decorated_title = Text()
|
|
decorated_title.append_text(left_effect)
|
|
decorated_title.append(' ')
|
|
decorated_title.append(title_text, style='bold #e8fdff')
|
|
decorated_title.append(' ')
|
|
decorated_title.append_text(right_effect)
|
|
available_width = self.size.width - self._HEADER_HORIZONTAL_PADDING
|
|
if available_width <= 0:
|
|
available_width = self._HEADER_FALLBACK_WIDTH
|
|
centered = Text(
|
|
' ' * max((max(available_width, len(decorated_title.plain)) - len(decorated_title.plain)) // 2, 0)
|
|
)
|
|
centered.append_text(decorated_title)
|
|
return centered
|
|
|
|
def render(self) -> Text:
|
|
"""
|
|
渲染顶部状态栏文本。
|
|
|
|
:return: 渲染富文本
|
|
"""
|
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
lines = TUI_COPY.build_workspace_header_lines(
|
|
env=self.env,
|
|
view_label=TUI_COPY.render_view_label(self.active_view),
|
|
timestamp=timestamp,
|
|
shortcut_hint=TUI_KEYMAP_REGISTRY.global_shortcut_hint,
|
|
)
|
|
rendered = Text()
|
|
if len(lines) >= self._HEADER_LINE_COUNT:
|
|
rendered.append_text(self.build_centered_title_line(lines[0]))
|
|
rendered.append('\n')
|
|
rendered.append(lines[1], style='bold #c7f6ff')
|
|
return rendered
|
|
rendered.append('\n'.join(lines), style='bold #c7f6ff')
|
|
return rendered
|
|
|
|
|
|
class SectionListItem(ListItem):
|
|
"""
|
|
详情页分区导航列表项。
|
|
|
|
:param section: 分区快照
|
|
:param index: 分区索引
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
SectionListItem {
|
|
padding: 0 1;
|
|
margin-bottom: 1;
|
|
border: round #123148;
|
|
background: #07131f;
|
|
color: #a3c8d2;
|
|
height: auto;
|
|
}
|
|
|
|
SectionListItem Label {
|
|
width: 1fr;
|
|
}
|
|
|
|
SectionListItem.is-ok {
|
|
border: round #22c983;
|
|
background: #081b17;
|
|
}
|
|
|
|
SectionListItem.is-fail {
|
|
border: round #ff6b7a;
|
|
background: #241118;
|
|
}
|
|
|
|
SectionListItem.is-warn {
|
|
border: round #f2b84b;
|
|
background: #261d0f;
|
|
}
|
|
|
|
SectionListItem.is-info {
|
|
border: round #38d8ff;
|
|
background: #081825;
|
|
}
|
|
|
|
SectionListItem.-highlight {
|
|
border: heavy #6df7ff;
|
|
background: #10364b;
|
|
color: #ffffff;
|
|
text-style: bold;
|
|
padding-left: 2;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
section: DetailSectionSnapshot,
|
|
index: int,
|
|
query: str = '',
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化详情页分区导航项。
|
|
|
|
:param section: 分区快照
|
|
:param index: 分区索引
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.section = section
|
|
self.index = index
|
|
self.search_query = query
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
preview_line = self.rendering.build_preview_line(section.lines, TUI_COPY.build_workspace_empty_text('detail'))
|
|
super().__init__(
|
|
Label(
|
|
'\n'.join(
|
|
TUI_COPY.build_section_item_lines(
|
|
index,
|
|
self.rendering.highlight_search_text(section.title, query),
|
|
self.rendering.render_status_badge(section.status),
|
|
self.rendering.highlight_search_text(preview_line, query),
|
|
)
|
|
),
|
|
markup=False,
|
|
)
|
|
)
|
|
self.add_class(self.rendering.resolve_status_class(section.status))
|
|
|
|
|
|
class SectionNavigator(ListView):
|
|
"""
|
|
详情页分区导航组件。
|
|
|
|
:param sections: 分区快照列表
|
|
:param initial_index: 初始高亮索引
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
SectionNavigator {
|
|
width: 30;
|
|
min-width: 30;
|
|
border: double #18425d;
|
|
padding: 1;
|
|
margin-right: 1;
|
|
background: #06111b;
|
|
}
|
|
|
|
SectionNavigator:focus {
|
|
border: heavy #6df7ff;
|
|
}
|
|
"""
|
|
|
|
class Changed(Message):
|
|
"""
|
|
分区导航高亮变更消息。
|
|
"""
|
|
|
|
def __init__(self, navigator: 'SectionNavigator', index: int, item: SectionListItem) -> None:
|
|
super().__init__()
|
|
self.navigator = navigator
|
|
self.index = index
|
|
self.item = item
|
|
|
|
@property
|
|
def control(self) -> 'SectionNavigator':
|
|
return self.navigator
|
|
|
|
def __init__(
|
|
self,
|
|
sections: list[DetailSectionSnapshot],
|
|
initial_index: int = 0,
|
|
query: str = '',
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化详情页分区导航。
|
|
|
|
:param sections: 分区快照列表
|
|
:param initial_index: 初始高亮索引
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.sections = sections
|
|
self.search_query = query
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
children = [
|
|
SectionListItem(section, index, query=query, rendering=self.rendering)
|
|
for index, section in enumerate(sections)
|
|
]
|
|
super().__init__(*children, initial_index=initial_index)
|
|
|
|
def watch_index(self, old_index: int | None, new_index: int | None) -> None:
|
|
"""
|
|
在高亮变化后向父级发送明确的分区切换消息。
|
|
|
|
:param old_index: 旧索引
|
|
:param new_index: 新索引
|
|
:return: None
|
|
"""
|
|
super().watch_index(old_index, new_index)
|
|
if new_index is None or not 0 <= new_index < len(self._nodes):
|
|
return
|
|
item = self._nodes[new_index]
|
|
if isinstance(item, SectionListItem):
|
|
self.post_message(self.Changed(self, new_index, item))
|
|
|
|
async def show_sections(
|
|
self, sections: list[DetailSectionSnapshot], initial_index: int = 0, query: str = ''
|
|
) -> None:
|
|
"""
|
|
刷新分区导航内容并恢复高亮位置。
|
|
|
|
:param sections: 最新分区快照列表
|
|
:param initial_index: 目标高亮索引
|
|
:return: None
|
|
"""
|
|
self.sections = sections
|
|
self.search_query = query
|
|
await self.clear()
|
|
children = [
|
|
SectionListItem(section, index, query=query, rendering=self.rendering)
|
|
for index, section in enumerate(sections)
|
|
]
|
|
if not children:
|
|
self.index = None
|
|
return
|
|
await self.extend(children)
|
|
self.index = max(0, min(initial_index, len(children) - 1))
|
|
|
|
|
|
class SectionDetailView(Static):
|
|
"""
|
|
详情页分区内容展示组件。
|
|
|
|
:param section: 当前展示的分区快照
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
SectionDetailView {
|
|
border: double #245874;
|
|
padding: 1 2;
|
|
background: #08131f;
|
|
color: #ddf8ff;
|
|
width: 1fr;
|
|
min-height: 18;
|
|
overflow-x: auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
SectionDetailView.is-ok {
|
|
border: double #22c983;
|
|
background: #081b17;
|
|
}
|
|
|
|
SectionDetailView.is-fail {
|
|
border: double #ff6b7a;
|
|
background: #241118;
|
|
}
|
|
|
|
SectionDetailView.is-warn {
|
|
border: double #f2b84b;
|
|
background: #261d0f;
|
|
}
|
|
|
|
SectionDetailView.is-info {
|
|
border: double #38d8ff;
|
|
background: #081825;
|
|
}
|
|
|
|
SectionDetailView:focus {
|
|
border: heavy #6df7ff;
|
|
background: #10364b;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
section: DetailSectionSnapshot,
|
|
query: str = '',
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化详情页分区内容展示组件。
|
|
|
|
:param section: 当前展示的分区快照
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.section = section
|
|
self.search_query = query
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
super().__init__(markup=False)
|
|
self.can_focus = True
|
|
self._render_section()
|
|
|
|
def show_section(self, section: DetailSectionSnapshot, query: str = '') -> None:
|
|
"""
|
|
切换并渲染当前展示的分区内容。
|
|
|
|
:param section: 待展示的分区快照
|
|
:return: None
|
|
"""
|
|
self.section = section
|
|
self.search_query = query
|
|
self._render_section()
|
|
|
|
def _render_section(self) -> None:
|
|
"""
|
|
渲染当前分区内容。
|
|
|
|
:return: None
|
|
"""
|
|
self.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info')
|
|
self.add_class(self.rendering.resolve_status_class(self.section.status))
|
|
highlighted_lines = [
|
|
self.rendering.highlight_search_text(line, self.search_query) for line in self.section.lines
|
|
]
|
|
body = self.rendering.render_structured_lines(
|
|
highlighted_lines,
|
|
TUI_COPY.build_workspace_empty_text('detail'),
|
|
)
|
|
self.update(
|
|
'\n'.join(
|
|
TUI_COPY.build_section_detail_lines(
|
|
self.rendering.highlight_search_text(self.section.title, self.search_query),
|
|
TUI_COPY.render_status_label(self.section.status),
|
|
self.rendering.render_status_badge(self.section.status),
|
|
body,
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
class RecordListItem(ListItem):
|
|
"""
|
|
浏览页记录导航列表项。
|
|
|
|
:param record: 记录快照
|
|
:param index: 记录索引
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
RecordListItem {
|
|
padding: 0 1;
|
|
margin-bottom: 1;
|
|
border: round #123148;
|
|
background: #07131f;
|
|
color: #a3c8d2;
|
|
height: auto;
|
|
}
|
|
|
|
RecordListItem Label {
|
|
width: 1fr;
|
|
}
|
|
|
|
RecordListItem.is-ok {
|
|
border: round #22c983;
|
|
background: #081b17;
|
|
}
|
|
|
|
RecordListItem.is-fail {
|
|
border: round #ff6b7a;
|
|
background: #241118;
|
|
}
|
|
|
|
RecordListItem.is-warn {
|
|
border: round #f2b84b;
|
|
background: #261d0f;
|
|
}
|
|
|
|
RecordListItem.is-info {
|
|
border: round #38d8ff;
|
|
background: #081825;
|
|
}
|
|
|
|
RecordListItem.-highlight {
|
|
border: heavy #6df7ff;
|
|
background: #10364b;
|
|
color: #ffffff;
|
|
text-style: bold;
|
|
padding-left: 2;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
record: BrowserRecordSnapshot,
|
|
index: int,
|
|
query: str = '',
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化浏览页记录导航项。
|
|
|
|
:param record: 记录快照
|
|
:param index: 记录索引
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.record = record
|
|
self.index = index
|
|
self.search_query = query
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
super().__init__(
|
|
Label(
|
|
'\n'.join(
|
|
TUI_COPY.build_record_item_lines(
|
|
index,
|
|
self.rendering.highlight_search_text(record.title, query),
|
|
self.rendering.render_status_badge(record.status),
|
|
self.rendering.highlight_search_text(record.summary, query),
|
|
)
|
|
),
|
|
markup=False,
|
|
)
|
|
)
|
|
self.add_class(self.rendering.resolve_status_class(record.status))
|
|
|
|
|
|
class RecordNavigator(ListView):
|
|
"""
|
|
浏览页记录导航组件。
|
|
|
|
:param records: 记录快照列表
|
|
:param initial_index: 初始高亮索引
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
RecordNavigator {
|
|
width: 34;
|
|
min-width: 34;
|
|
border: double #18425d;
|
|
padding: 1;
|
|
margin-right: 1;
|
|
background: #06111b;
|
|
}
|
|
|
|
RecordNavigator:focus {
|
|
border: heavy #6df7ff;
|
|
}
|
|
"""
|
|
|
|
class Changed(Message):
|
|
"""
|
|
记录导航高亮变更消息。
|
|
"""
|
|
|
|
def __init__(self, navigator: 'RecordNavigator', index: int, item: RecordListItem) -> None:
|
|
super().__init__()
|
|
self.navigator = navigator
|
|
self.index = index
|
|
self.item = item
|
|
|
|
@property
|
|
def control(self) -> 'RecordNavigator':
|
|
return self.navigator
|
|
|
|
def __init__(
|
|
self,
|
|
records: list[BrowserRecordSnapshot],
|
|
initial_index: int = 0,
|
|
query: str = '',
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化浏览页记录导航。
|
|
|
|
:param records: 记录快照列表
|
|
:param initial_index: 初始高亮索引
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.records = records
|
|
self.search_query = query
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
children = [
|
|
RecordListItem(record, index, query=query, rendering=self.rendering) for index, record in enumerate(records)
|
|
]
|
|
super().__init__(*children, initial_index=initial_index, id='record-navigator')
|
|
|
|
def watch_index(self, old_index: int | None, new_index: int | None) -> None:
|
|
"""
|
|
在高亮变化后向父级发送明确的记录切换消息。
|
|
|
|
:param old_index: 旧索引
|
|
:param new_index: 新索引
|
|
:return: None
|
|
"""
|
|
super().watch_index(old_index, new_index)
|
|
if new_index is None or not 0 <= new_index < len(self._nodes):
|
|
return
|
|
item = self._nodes[new_index]
|
|
if isinstance(item, RecordListItem):
|
|
self.post_message(self.Changed(self, new_index, item))
|
|
|
|
|
|
class RecordDetailView(Static):
|
|
"""
|
|
浏览页记录详情展示组件。
|
|
|
|
:param record: 当前展示的记录快照
|
|
:param shared_sections: 页面共享分区列表
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
RecordDetailView {
|
|
border: double #245874;
|
|
padding: 1 2;
|
|
background: #08131f;
|
|
color: #ddf8ff;
|
|
width: 1fr;
|
|
min-height: 18;
|
|
overflow-x: auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
RecordDetailView.is-ok {
|
|
border: double #22c983;
|
|
}
|
|
|
|
RecordDetailView.is-fail {
|
|
border: double #ff6b7a;
|
|
}
|
|
|
|
RecordDetailView.is-warn {
|
|
border: double #f2b84b;
|
|
}
|
|
|
|
RecordDetailView.is-info {
|
|
border: double #38d8ff;
|
|
}
|
|
|
|
RecordDetailView:focus {
|
|
border: heavy #6df7ff;
|
|
background: #10364b;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
record: BrowserRecordSnapshot,
|
|
shared_sections: list[DetailSectionSnapshot],
|
|
query: str = '',
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化浏览页记录详情展示组件。
|
|
|
|
:param record: 当前展示的记录快照
|
|
:param shared_sections: 页面共享分区列表
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.record = record
|
|
self.shared_sections = shared_sections
|
|
self.search_query = query
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
super().__init__(id='record-detail-view', markup=False)
|
|
self.can_focus = True
|
|
self._render_record()
|
|
|
|
def show_record(
|
|
self,
|
|
record: BrowserRecordSnapshot,
|
|
shared_sections: list[DetailSectionSnapshot],
|
|
query: str = '',
|
|
) -> None:
|
|
"""
|
|
切换并渲染当前展示的记录内容。
|
|
|
|
:param record: 待展示的记录快照
|
|
:param shared_sections: 页面共享分区列表
|
|
:return: None
|
|
"""
|
|
self.record = record
|
|
self.shared_sections = shared_sections
|
|
self.search_query = query
|
|
self._render_record()
|
|
|
|
def _render_record(self) -> None:
|
|
"""
|
|
渲染当前记录内容。
|
|
|
|
:return: None
|
|
"""
|
|
self.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info')
|
|
self.add_class(self.rendering.resolve_status_class(self.record.status))
|
|
lines = [
|
|
(
|
|
f'{self.rendering.render_status_badge(self.record.status)} '
|
|
f'{self.rendering.highlight_search_text(self.record.title, self.search_query)}'
|
|
),
|
|
f'{TUI_COPY.build_workspace_label("overview")} · {self.rendering.highlight_search_text(self.record.summary, self.search_query)}',
|
|
'',
|
|
]
|
|
if self.record.metadata_lines:
|
|
lines.append(TUI_COPY.build_workspace_title('key_info'))
|
|
lines.append('────────────────')
|
|
lines.append(
|
|
self.rendering.render_structured_lines(
|
|
[
|
|
self.rendering.highlight_search_text(line, self.search_query)
|
|
for line in self.record.metadata_lines
|
|
],
|
|
TUI_COPY.build_workspace_empty_text('key_info'),
|
|
)
|
|
)
|
|
lines.append('')
|
|
|
|
combined_sections = [*self.record.detail_sections, *self.shared_sections]
|
|
if not combined_sections:
|
|
lines.append(TUI_COPY.build_workspace_title('detail_content'))
|
|
lines.append('========')
|
|
lines.append(f'- {TUI_COPY.build_workspace_empty_text("detail")}')
|
|
else:
|
|
for section in combined_sections:
|
|
lines.append('-' * 44)
|
|
lines.append(
|
|
f'{self.rendering.render_status_badge(section.status)} '
|
|
f'{self.rendering.highlight_search_text(section.title, self.search_query)}'
|
|
)
|
|
lines.append(
|
|
self.rendering.render_structured_lines(
|
|
[self.rendering.highlight_search_text(line, self.search_query) for line in section.lines],
|
|
TUI_COPY.build_workspace_empty_text('detail'),
|
|
)
|
|
)
|
|
lines.append('')
|
|
self.update('\n'.join(lines).strip())
|
|
|
|
|
|
class RecordSummaryView(Static):
|
|
"""
|
|
浏览页记录摘要展示组件。
|
|
|
|
:param record: 当前展示的记录快照
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
RecordSummaryView {
|
|
border: double #38d8ff;
|
|
padding: 1 2;
|
|
margin-bottom: 1;
|
|
background: #081827;
|
|
color: #e9fcff;
|
|
overflow-x: auto;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
RecordSummaryView.is-ok {
|
|
border: double #22c983;
|
|
background: #081b17;
|
|
}
|
|
|
|
RecordSummaryView.is-fail {
|
|
border: double #ff6b7a;
|
|
background: #241118;
|
|
}
|
|
|
|
RecordSummaryView.is-warn {
|
|
border: double #f2b84b;
|
|
background: #261d0f;
|
|
}
|
|
|
|
RecordSummaryView.is-info {
|
|
border: double #38d8ff;
|
|
background: #081825;
|
|
}
|
|
|
|
RecordSummaryView.section-ok {
|
|
border-left: heavy #22c983;
|
|
}
|
|
|
|
RecordSummaryView.section-fail {
|
|
border-left: heavy #ff6b7a;
|
|
}
|
|
|
|
RecordSummaryView.section-warn {
|
|
border-left: heavy #f2b84b;
|
|
}
|
|
|
|
RecordSummaryView.section-info {
|
|
border-left: heavy #38d8ff;
|
|
}
|
|
|
|
RecordSummaryView:focus {
|
|
border: heavy #6df7ff;
|
|
background: #10364b;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
record: BrowserRecordSnapshot,
|
|
rendering: WorkspaceRenderingSupport | None = None,
|
|
) -> None:
|
|
"""
|
|
初始化浏览页记录摘要展示组件。
|
|
|
|
:param record: 当前展示的记录快照
|
|
:param rendering: 工作台组件渲染支持对象
|
|
:return: None
|
|
"""
|
|
self.record = record
|
|
self.section: DetailSectionSnapshot | None = None
|
|
self.action_lines: list[str] = []
|
|
self.search_query = ''
|
|
self.rendering = rendering or WORKSPACE_RENDERING
|
|
super().__init__(id='record-summary-view', markup=False)
|
|
self.can_focus = True
|
|
self._render_record()
|
|
|
|
def show_record(
|
|
self,
|
|
record: BrowserRecordSnapshot,
|
|
selected_section: DetailSectionSnapshot | None = None,
|
|
action_lines: list[str] | None = None,
|
|
query: str = '',
|
|
) -> None:
|
|
"""
|
|
更新当前展示的记录摘要。
|
|
|
|
:param record: 待展示的记录快照
|
|
:param selected_section: 当前联动分区
|
|
:param action_lines: 动作摘要文本
|
|
:return: None
|
|
"""
|
|
self.record = record
|
|
self.section = selected_section
|
|
self.action_lines = list(action_lines or [])
|
|
self.search_query = query
|
|
self._render_record()
|
|
|
|
def _render_record(self) -> None:
|
|
"""
|
|
渲染当前记录摘要。
|
|
|
|
:return: None
|
|
"""
|
|
self.remove_class('is-ok', 'is-fail', 'is-warn', 'is-info')
|
|
self.remove_class('section-ok', 'section-fail', 'section-warn', 'section-info')
|
|
self.add_class(self.rendering.resolve_status_class(self.record.status))
|
|
if self.section is not None:
|
|
self.add_class(
|
|
f'section-{self.section.status if self.section.status in {"ok", "fail", "warn"} else "info"}'
|
|
)
|
|
lines = [
|
|
(
|
|
f'{self.rendering.render_status_badge(self.record.status)} '
|
|
f'{self.rendering.highlight_search_text(self.record.title, self.search_query)}'
|
|
),
|
|
f'{TUI_COPY.build_workspace_label("overview")} · {self.rendering.highlight_search_text(self.record.summary, self.search_query)}',
|
|
]
|
|
if self.section is not None:
|
|
lines.append(
|
|
f'{TUI_COPY.build_workspace_label("current_section")} · {self.rendering.highlight_search_text(self.section.title, self.search_query)} / {TUI_COPY.render_status_label(self.section.status)}'
|
|
)
|
|
if self.record.metadata_lines:
|
|
lines.append('')
|
|
lines.append(TUI_COPY.build_workspace_title('key_fields'))
|
|
lines.append('────────────────')
|
|
lines.append(
|
|
self.rendering.render_structured_lines(
|
|
[
|
|
self.rendering.highlight_search_text(line, self.search_query)
|
|
for line in self.record.metadata_lines
|
|
],
|
|
TUI_COPY.build_workspace_empty_text('key_info'),
|
|
)
|
|
)
|
|
if self.action_lines:
|
|
lines.append('')
|
|
lines.append(TUI_COPY.build_workspace_title('action_feedback'))
|
|
lines.append('────────────────')
|
|
lines.append(
|
|
self.rendering.render_structured_lines(
|
|
[self.rendering.highlight_search_text(line, self.search_query) for line in self.action_lines],
|
|
TUI_COPY.build_workspace_empty_text('actions'),
|
|
)
|
|
)
|
|
self.update('\n'.join(lines))
|