初始提交

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,3 @@
from cli.tui.commands import TUI_COMMAND_REGISTRATION
__all__ = ['TUI_COMMAND_REGISTRATION']
@@ -0,0 +1,10 @@
from cli.tui.actions.bootstrap import TUI_ACTION_EXECUTION_SERVICE, TUI_ACTION_PRESENTATION_SERVICE, TUI_ACTION_REGISTRY
from cli.tui.actions.models import TuiActionResult, TuiActionSpec
__all__ = [
'TUI_ACTION_EXECUTION_SERVICE',
'TUI_ACTION_PRESENTATION_SERVICE',
'TUI_ACTION_REGISTRY',
'TuiActionResult',
'TuiActionSpec',
]
@@ -0,0 +1,103 @@
from dataclasses import dataclass
from cli.tui.actions.builders import TuiActionSpecFactory
from cli.tui.actions.factories.cache import CacheActionTemplateFactory
from cli.tui.actions.factories.gen import GenActionTemplateFactory
from cli.tui.actions.factories.jobs import JobActionTemplateFactory
from cli.tui.actions.factories.static import StaticActionTemplateFactory
from cli.tui.actions.registry import TuiActionRegistry, TuiActionSlotResolver
@dataclass(frozen=True)
class TuiActionRegistryBuilder:
"""
TUI 动作注册表构建器。
该对象负责装配各领域动作模板工厂,最终输出浏览页和详情页共用的
动作注册表,避免模板常量继续散落在模块底部。
:param jobs: 任务页动作模板工厂
:param cache: 缓存页动作模板工厂
:param gen: 代码生成页动作模板工厂
:param static: 静态页面动作模板工厂
:param spec_factory: 动作规格构建器
"""
jobs: JobActionTemplateFactory
cache: CacheActionTemplateFactory
gen: GenActionTemplateFactory
static: StaticActionTemplateFactory
spec_factory: TuiActionSpecFactory
def build(self) -> TuiActionRegistry:
"""
构建 TUI 动作注册表。
:return: 动作注册表
"""
return TuiActionRegistry(
browser_resolvers={
'jobs': TuiActionSlotResolver(
slot_templates={
'primary': self.jobs.create_run_once_template(),
'secondary': self.jobs.create_toggle_template(),
'global': self.jobs.create_sync_template(),
},
spec_factory=self.spec_factory,
),
'configs': TuiActionSlotResolver(
slot_templates={
'global': self.static.create_config_sync_template(),
},
spec_factory=self.spec_factory,
),
'cache': TuiActionSlotResolver(
slot_templates={
'global': self.cache.create_clear_wizard_template(),
'utility': self.cache.create_warmup_template(),
},
spec_factory=self.spec_factory,
),
'gen': TuiActionSlotResolver(
slot_templates={
'primary': self.gen.create_export_wizard_template(),
'secondary': self.gen.create_import_wizard_template(),
'global': self.gen.create_export_dry_run_template(),
'utility': self.gen.create_sync_db_template(),
},
spec_factory=self.spec_factory,
),
},
detail_resolvers={
'app': TuiActionSlotResolver(
slot_templates={
'primary': self.static.create_app_run_template(),
'global': self.static.create_app_run_wizard_template(),
'utility': self.static.create_completion_install_template(),
},
spec_factory=self.spec_factory,
),
'database': TuiActionSlotResolver(
slot_templates={
'global': self.static.create_db_upgrade_wizard_template(),
'utility': self.static.create_db_init_dry_run_template(),
},
spec_factory=self.spec_factory,
),
'ops': TuiActionSlotResolver(
slot_templates={
'primary': self.static.create_ops_ping_db_template(),
'secondary': self.static.create_ops_ping_redis_template(),
'global': self.static.create_prod_check_wizard_template(),
},
spec_factory=self.spec_factory,
),
'crypto': TuiActionSlotResolver(
slot_templates={
'primary': self.static.create_crypto_keygen_template(),
'global': self.static.create_crypto_rotate_dry_run_template(),
},
spec_factory=self.spec_factory,
),
},
)
@@ -0,0 +1,31 @@
from cli.tui.actions.assembly import TuiActionRegistryBuilder
from cli.tui.actions.builders import TuiActionSpecFactory, TuiActionTemplateSupport
from cli.tui.actions.execution import TuiActionExecutionService
from cli.tui.actions.factories.cache import CacheActionTemplateFactory
from cli.tui.actions.factories.gen import GenActionTemplateFactory
from cli.tui.actions.factories.jobs import JobActionTemplateFactory
from cli.tui.actions.factories.static import StaticActionTemplateFactory
from cli.tui.actions.presentation import TuiActionPresentationService
from cli.tui.capabilities import TUI_CAPABILITY_REGISTRY
_ACTION_SPEC_FACTORY = TuiActionSpecFactory()
TUI_ACTION_EXECUTION_SERVICE = TuiActionExecutionService()
_ACTION_TEMPLATE_SUPPORT = TuiActionTemplateSupport(spec_factory=_ACTION_SPEC_FACTORY)
_JOB_ACTION_TEMPLATE_FACTORY = JobActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
_CACHE_ACTION_TEMPLATE_FACTORY = CacheActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
_GEN_ACTION_TEMPLATE_FACTORY = GenActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
_STATIC_ACTION_TEMPLATE_FACTORY = StaticActionTemplateFactory(support=_ACTION_TEMPLATE_SUPPORT)
_ACTION_REGISTRY_BUILDER = TuiActionRegistryBuilder(
jobs=_JOB_ACTION_TEMPLATE_FACTORY,
cache=_CACHE_ACTION_TEMPLATE_FACTORY,
gen=_GEN_ACTION_TEMPLATE_FACTORY,
static=_STATIC_ACTION_TEMPLATE_FACTORY,
spec_factory=_ACTION_SPEC_FACTORY,
)
TUI_ACTION_REGISTRY = _ACTION_REGISTRY_BUILDER.build()
TUI_ACTION_PRESENTATION_SERVICE = TuiActionPresentationService(
capability_registry=TUI_CAPABILITY_REGISTRY,
action_registry=TUI_ACTION_REGISTRY,
)
@@ -0,0 +1,329 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from cli.tui.actions.models import (
ActionCommandBuilder,
ActionExecutionMode,
ActionSummaryBuilder,
ActionTextBuilder,
TuiActionSpec,
)
from cli.tui.copy import TUI_COPY
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
if TYPE_CHECKING:
from cli.tui.adapters.models import BrowserRecordSnapshot
@dataclass(frozen=True)
class TuiActionSpecFactory:
"""
TUI 动作规格构建器。
该对象负责统一构建动作预览文本与 `TuiActionSpec`,
避免各动作函数继续手工拼接重复的预览和规格对象。
"""
@staticmethod
def parse_record_suffix(record: BrowserRecordSnapshot, prefix: str) -> str:
"""
从记录键中提取指定前缀后的标识。
:param record: 浏览记录
:param prefix: 键名前缀
:return: 后缀标识
"""
raw_key = str(record.key).strip()
token_prefix = f'{prefix}:'
if not raw_key.startswith(token_prefix):
return ''
return raw_key[len(token_prefix) :].strip()
def build_command_preview_lines(
self,
*,
env: str,
summary_lines: list[str],
command_args: tuple[str, ...],
append_yes: bool = True,
consequence_text: str = TUI_COPY.build_action_consequence_text('preview'),
) -> list[str]:
"""
构建统一的动作预览文本。
:param env: 当前运行环境
:param summary_lines: 业务摘要文本
:param command_args: 对应 CLI 命令参数
:param consequence_text: 结果影响说明
:return: 预览文本
"""
nested_cli_arguments = [
*command_args,
f'--env={env}',
'--output=json',
]
if append_yes:
nested_cli_arguments.append('--yes')
command = SHELL_TEXT_FORMATTER.format_shell_command(
NESTED_CLI_SUPPORT.build_nested_cli_command(*nested_cli_arguments)
)
return [
TUI_COPY.build_action_preview_title('summary'),
*summary_lines,
'',
TUI_COPY.build_action_preview_title('env'),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('target_env'), env),
'',
TUI_COPY.build_action_preview_title('command'),
command,
'',
TUI_COPY.build_action_preview_title('consequence'),
consequence_text,
]
def build_external_command_preview_lines(
self,
*,
env: str,
summary_lines: list[str],
command_args: tuple[str, ...],
append_yes: bool = True,
consequence_text: str = TUI_COPY.build_action_consequence_text('wizard'),
) -> list[str]:
"""
构建外部交互命令的预览文本。
:param env: 当前运行环境
:param summary_lines: 业务摘要文本
:param command_args: 对应 CLI 命令参数
:param consequence_text: 结果影响说明
:return: 预览文本
"""
return self.build_command_preview_lines(
env=env,
summary_lines=summary_lines,
command_args=command_args,
append_yes=append_yes,
consequence_text=consequence_text,
)
def build_nested_json_action(
self,
*,
action_id: str,
label: str,
command_args: tuple[str, ...],
env: str,
summary_lines: list[str],
preview_title: str | None = None,
consequence_text: str = TUI_COPY.build_action_consequence_text('preview'),
append_yes: bool = True,
refresh_view: bool = True,
) -> TuiActionSpec:
"""
构建标准 nested JSON 动作。
:param action_id: 动作唯一标识
:param label: 动作显示名称
:param command_args: 对应 CLI 命令参数
:param env: 当前运行环境
:param summary_lines: 业务摘要文本
:param preview_title: 预览标题
:param consequence_text: 结果影响说明
:param refresh_view: 执行后是否刷新视图
:return: 动作定义
"""
return TuiActionSpec(
action_id=action_id,
label=label,
command_args=command_args,
preview_title=preview_title or label,
preview_lines=self.build_command_preview_lines(
env=env,
summary_lines=summary_lines,
command_args=command_args,
append_yes=append_yes,
consequence_text=consequence_text,
),
append_yes=append_yes,
refresh_view=refresh_view,
)
def build_external_action(
self,
*,
action_id: str,
label: str,
command_args: tuple[str, ...],
env: str,
summary_lines: list[str],
preview_title: str | None = None,
consequence_text: str = TUI_COPY.build_action_consequence_text('wizard'),
append_yes: bool = True,
refresh_view: bool = True,
) -> TuiActionSpec:
"""
构建标准外部交互动作。
:param action_id: 动作唯一标识
:param label: 动作显示名称
:param command_args: 对应 CLI 命令参数
:param env: 当前运行环境
:param summary_lines: 业务摘要文本
:param preview_title: 预览标题
:param consequence_text: 结果影响说明
:param refresh_view: 执行后是否刷新视图
:return: 动作定义
"""
return TuiActionSpec(
action_id=action_id,
label=label,
command_args=command_args,
preview_title=preview_title or label,
preview_lines=self.build_external_command_preview_lines(
env=env,
summary_lines=summary_lines,
command_args=command_args,
append_yes=append_yes,
consequence_text=consequence_text,
),
execution_mode='external',
append_yes=append_yes,
refresh_view=refresh_view,
)
@dataclass(frozen=True)
class TuiActionTemplate:
"""
TUI 动作模板。
该模板将命令参数构建、摘要构建与执行模式收敛为统一对象,
使动作注册层可以直接声明“某个槽位对应哪类动作”。
:param action_id: 动作唯一标识
:param label: 动作显示名称
:param command_builder: 命令参数构建器
:param summary_builder: 预览摘要构建器
:param action_id_builder: 动态动作标识构建器
:param label_builder: 动态动作标题构建器
:param preview_title: 预览标题
:param consequence_text: 结果影响说明
:param execution_mode: 执行模式
:param append_yes: 是否在命令执行与预览中自动追加 `--yes`
:param refresh_view: 执行后是否刷新视图
:param preview_env_override: 预览中展示的环境名覆盖值
"""
action_id: str
label: str
command_builder: ActionCommandBuilder
summary_builder: ActionSummaryBuilder
action_id_builder: ActionTextBuilder | None = None
label_builder: ActionTextBuilder | None = None
preview_title: str | None = None
consequence_text: str = TUI_COPY.build_action_consequence_text('preview')
execution_mode: ActionExecutionMode = 'nested_json'
append_yes: bool = True
refresh_view: bool = True
preview_env_override: str | None = None
def build(
self,
*,
record: BrowserRecordSnapshot | None,
env: str,
spec_factory: TuiActionSpecFactory,
) -> TuiActionSpec | None:
"""
根据当前页面上下文构建动作定义。
:param record: 当前选中记录
:param env: 当前运行环境
:param spec_factory: 动作规格构建器
:return: 动作定义
"""
command_args = self.command_builder(record, env)
if command_args is None:
return None
preview_env = self.preview_env_override or env
summary_lines = self.summary_builder(record, env)
action_id = self.action_id_builder(record, env) if self.action_id_builder is not None else self.action_id
label = self.label_builder(record, env) if self.label_builder is not None else self.label
if self.execution_mode == 'external':
return spec_factory.build_external_action(
action_id=action_id,
label=label,
command_args=command_args,
env=preview_env,
summary_lines=summary_lines,
preview_title=self.preview_title,
consequence_text=self.consequence_text,
append_yes=self.append_yes,
refresh_view=self.refresh_view,
)
return spec_factory.build_nested_json_action(
action_id=action_id,
label=label,
command_args=command_args,
env=preview_env,
summary_lines=summary_lines,
preview_title=self.preview_title,
consequence_text=self.consequence_text,
append_yes=self.append_yes,
refresh_view=self.refresh_view,
)
@dataclass(frozen=True)
class TuiActionTemplateSupport:
"""
TUI 动作模板共享构建支持。
该对象集中封装多个动作模板都会复用的记录解析与摘要拼装逻辑,
避免这些基础能力继续散落在模块级函数中。
:param spec_factory: 动作规格构建器
"""
spec_factory: TuiActionSpecFactory
def build_scope_purpose_summary(self, scope: str, purpose: str) -> list[str]:
"""
构建通用的作用范围与目的摘要。
:param scope: 作用范围说明
:param purpose: 目的说明
:return: 预览摘要
"""
return [
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('scope'), scope),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('purpose'), purpose),
]
def extract_job_id(self, record: BrowserRecordSnapshot | None) -> str | None:
"""
从任务记录中提取任务 ID。
:param record: 当前记录
:return: 任务 ID
"""
if record is None:
return None
job_id = self.spec_factory.parse_record_suffix(record, 'job')
return job_id if job_id.isdigit() else None
@staticmethod
def require_record_title(record: BrowserRecordSnapshot | None) -> str | None:
"""
提取记录标题作为命令目标。
:param record: 当前记录
:return: 标题文本
"""
if record is None:
return None
title = str(record.title).strip()
return title or None
@@ -0,0 +1,121 @@
from dataclasses import dataclass
from cli.tui.actions.models import TuiActionResult, TuiActionSpec
from cli.tui.copy import TUI_COPY
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class TuiActionExecutionService:
"""
TUI 动作执行服务。
该对象负责统一封装 nested JSON 动作、外部交互动作
与结果文本行的收口逻辑。
"""
def execute(self, spec: TuiActionSpec, env: str) -> TuiActionResult:
"""
执行指定 TUI 动作。
:param spec: 动作定义
:param env: 当前运行环境
:return: 动作执行结果
"""
command_arguments = [
*spec.command_args,
f'--env={env}',
'--output=json',
]
if spec.append_yes:
command_arguments.append('--yes')
payload = NESTED_CLI_SUPPORT.run(*command_arguments, parse_json=True).payload
return TuiActionResult(spec=spec, payload=payload)
def execute_external(self, spec: TuiActionSpec) -> TuiActionResult:
"""
在当前终端中执行交互式 TUI 动作。
:param spec: 动作定义
:return: 动作执行结果
"""
completed = NESTED_CLI_SUPPORT.run_live(*spec.command_args)
if completed.returncode == 0:
message = '外部交互命令已执行完成'
else:
message = f'外部交互命令执行失败,退出码 {completed.returncode}'
return TuiActionResult(
spec=spec,
external_exit_code=completed.returncode,
external_message=message,
)
def build_result_lines(self, result: TuiActionResult) -> list[str]:
"""
构建动作结果详情文本。
:param result: 动作执行结果
:return: 结果文本行
"""
payload = result.payload if isinstance(result.payload, dict) else {}
lines = [
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('name'),
result.spec.label,
),
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('outcome'),
TUI_COPY.build_action_result_field_label('success')
if result.ok
else TUI_COPY.build_action_result_field_label('fail'),
),
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('summary'),
SHELL_TEXT_FORMATTER.truncate_text(result.message, 88),
),
]
service_message = str(payload.get('serviceMessage', '') or '').strip()
if service_message:
lines.append(
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('service'),
SHELL_TEXT_FORMATTER.truncate_text(service_message, 88),
)
)
if payload.get('hint'):
lines.append(
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('hint'),
SHELL_TEXT_FORMATTER.truncate_text(str(payload.get('hint', '') or ''), 88),
)
)
if payload.get('count') is not None:
lines.append(
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('count'),
str(payload.get('count')),
)
)
if payload.get('jobId') is not None:
lines.append(
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('job_id'),
str(payload.get('jobId')),
)
)
if result.external_exit_code is not None:
lines.append(
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('exit_code'),
str(result.external_exit_code),
)
)
operation_label = str(payload.get('operationLabel', '') or '').strip()
if operation_label and operation_label != result.spec.label:
lines.append(
TUI_COPY.build_action_result_message_line(
TUI_COPY.build_action_result_field_label('operation'),
SHELL_TEXT_FORMATTER.truncate_text(operation_label, 64),
)
)
return lines
@@ -0,0 +1,83 @@
from dataclasses import dataclass
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
from cli.tui.adapters.models import BrowserRecordSnapshot
from cli.tui.copy import TUI_COPY
@dataclass(frozen=True)
class CacheActionTemplateFactory:
"""
缓存页动作模板工厂。
该对象负责生成缓存浏览页相关动作模板,统一缓存清理向导和预热动作
的命令与预览摘要构建。
:param support: 动作模板共享构建支持
"""
support: TuiActionTemplateSupport
def build_clear_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建缓存清理向导命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
cache_name = record.title if record is not None else ''
return (
'wizard',
'cache-clear',
'--output=text',
f'--default-env={env}',
'--default-mode=cache-name',
f'--default-cache-name={cache_name}',
'--default-dry-run',
)
def build_clear_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建缓存清理向导摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del record, env
return self.support.build_scope_purpose_summary(
'当前环境缓存清理流程',
'进入向导后确认缓存名、键前缀和 dry-run 范围',
)
def create_warmup_template(self) -> TuiActionTemplate:
"""
创建缓存预热动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='cache-warmup',
label=TUI_COPY.build_action_label('cache_warmup'),
command_builder=lambda record, env: ('cache', 'warmup'),
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('cache_warmup'),
TUI_COPY.build_action_purpose_label('cache_warmup'),
),
)
def create_clear_wizard_template(self) -> TuiActionTemplate:
"""
创建缓存清理向导动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='wizard-cache-clear',
label=TUI_COPY.build_action_label('cache_clear_wizard'),
command_builder=self.build_clear_command,
summary_builder=self.build_clear_summary,
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
)
@@ -0,0 +1,254 @@
from dataclasses import dataclass
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
from cli.tui.adapters.models import BrowserRecordSnapshot
from cli.tui.copy import TUI_COPY
@dataclass(frozen=True)
class GenActionTemplateFactory:
"""
代码生成页动作模板工厂。
该对象负责生成代码生成浏览页相关动作模板,统一记录标题提取、
导出导入向导和表结构同步动作的构建逻辑。
:param support: 动作模板共享构建支持
"""
support: TuiActionTemplateSupport
def build_export_wizard_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
"""
构建代码生成导出向导命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
table_name = self.support.require_record_title(record)
if table_name is None:
return None
return (
'wizard',
'gen-export',
'--output=text',
f'--default-env={env}',
f'--default-table-names={table_name}',
'--default-mode=zip',
'--default-dry-run',
)
def build_import_wizard_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
"""
构建代码生成导入向导命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
table_name = self.support.require_record_title(record)
if table_name is None:
return None
return (
'wizard',
'gen-import',
'--output=text',
f'--default-env={env}',
f'--default-table-names={table_name}',
'--default-dry-run',
)
def build_export_dry_run_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
"""
构建代码生成导出 dry-run 命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del env
table_name = self.support.require_record_title(record)
if table_name is None:
return None
return ('gen', 'export', table_name, '--dry-run', '--mode=zip')
def build_sync_db_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
"""
构建代码生成表结构同步命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del env
table_name = self.support.require_record_title(record)
if table_name is None:
return None
return ('gen', 'sync-db', table_name)
@staticmethod
def build_export_wizard_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建代码生成导出向导摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del env
if record is None:
return []
return [
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('scope'), f'当前业务表 {record.title}'
),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('current_status'),
record.summary,
),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('purpose'),
'进入向导后选择业务表、输出目录和覆盖策略',
),
]
@staticmethod
def build_import_wizard_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建代码生成导入向导摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del env
if record is None:
return []
return [
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('scope'), f'当前物理表 {record.title}'
),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('current_status'),
record.summary,
),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('purpose'),
'进入向导后确认物理表列表,并先执行 dry-run 导入预演',
),
]
@staticmethod
def build_export_dry_run_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建代码生成导出预演摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del env
if record is None:
return []
return [
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('scope'),
TUI_COPY.build_action_scope_label('gen_export_dry_run'),
),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('current_status'),
record.summary,
),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('purpose'),
TUI_COPY.build_action_purpose_label('gen_export_dry_run'),
),
]
@staticmethod
def build_sync_db_summary(record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建代码生成表结构同步摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del env
if record is None:
return []
return [
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('scope'),
TUI_COPY.build_action_scope_label('gen_sync_db'),
),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('current_status'),
record.summary,
),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('purpose'),
TUI_COPY.build_action_purpose_label('gen_sync_db'),
),
]
def create_export_wizard_template(self) -> TuiActionTemplate:
"""
创建代码生成导出向导动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='wizard-gen-export',
label=TUI_COPY.build_action_label('gen_export_wizard'),
command_builder=self.build_export_wizard_command,
summary_builder=self.build_export_wizard_summary,
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
refresh_view=False,
)
def create_import_wizard_template(self) -> TuiActionTemplate:
"""
创建代码生成导入向导动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='wizard-gen-import',
label=TUI_COPY.build_action_label('gen_import_wizard'),
command_builder=self.build_import_wizard_command,
summary_builder=self.build_import_wizard_summary,
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
refresh_view=False,
)
def create_export_dry_run_template(self) -> TuiActionTemplate:
"""
创建代码生成导出预演动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='gen-export-dry-run',
label=TUI_COPY.build_action_label('gen_export_dry_run'),
command_builder=self.build_export_dry_run_command,
summary_builder=self.build_export_dry_run_summary,
)
def create_sync_db_template(self) -> TuiActionTemplate:
"""
创建代码生成表结构同步动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='gen-sync-db',
label=TUI_COPY.build_action_label('gen_sync_db'),
command_builder=self.build_sync_db_command,
summary_builder=self.build_sync_db_summary,
)
@@ -0,0 +1,171 @@
from dataclasses import dataclass
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
from cli.tui.adapters.models import BrowserRecordSnapshot
from cli.tui.copy import TUI_COPY
@dataclass(frozen=True)
class JobActionTemplateFactory:
"""
任务页动作模板工厂。
该对象负责生成任务浏览页相关动作模板,收口任务 ID 解析、
暂停/恢复切换和摘要拼装逻辑。
:param support: 动作模板共享构建支持
"""
support: TuiActionTemplateSupport
def build_run_once_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
"""
构建任务执行一次命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del env
job_id = self.support.extract_job_id(record)
if job_id is None:
return None
return ('job', 'run-once', job_id)
def build_run_once_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建任务执行一次动作摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del env
if record is None:
return []
job_id = self.support.extract_job_id(record) or ''
return [
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job_id'), job_id),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('current_status'),
record.summary,
),
]
def build_toggle_command(self, record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...] | None:
"""
构建任务暂停或恢复命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del env
if record is None:
return None
job_id = self.support.extract_job_id(record)
if job_id is None:
return None
if record.status == 'warn':
return ('job', 'resume', job_id)
return ('job', 'pause', job_id)
def build_toggle_summary(self, record: BrowserRecordSnapshot | None, env: str) -> list[str]:
"""
构建任务暂停或恢复动作摘要。
:param record: 当前记录
:param env: 当前运行环境
:return: 预览摘要
"""
del env
if record is None:
return []
job_id = self.support.extract_job_id(record) or ''
label = (
TUI_COPY.build_action_label('job_resume')
if record.status == 'warn'
else TUI_COPY.build_action_label('job_pause')
)
return [
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job'), record.title),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('job_id'), job_id),
TUI_COPY.build_labeled_value_line(
TUI_COPY.build_action_preview_field_label('current_status'),
record.summary,
),
TUI_COPY.build_labeled_value_line(TUI_COPY.build_action_preview_field_label('target_action'), label),
]
@staticmethod
def build_toggle_action_id(record: BrowserRecordSnapshot | None, env: str) -> str:
"""
构建任务暂停或恢复动作标识。
:param record: 当前记录
:param env: 当前运行环境
:return: 动作标识
"""
del env
if record is not None and record.status == 'warn':
return 'job-resume'
return 'job-pause'
@staticmethod
def build_toggle_label(record: BrowserRecordSnapshot | None, env: str) -> str:
"""
构建任务暂停或恢复动作标题。
:param record: 当前记录
:param env: 当前运行环境
:return: 动作标题
"""
del env
if record is not None and record.status == 'warn':
return TUI_COPY.build_action_label('job_resume')
return TUI_COPY.build_action_label('job_pause')
def create_run_once_template(self) -> TuiActionTemplate:
"""
创建任务执行一次动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='job-run-once',
label=TUI_COPY.build_action_label('job_run_once'),
command_builder=self.build_run_once_command,
summary_builder=self.build_run_once_summary,
)
def create_toggle_template(self) -> TuiActionTemplate:
"""
创建任务暂停或恢复动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='job-toggle',
label=TUI_COPY.build_action_label('job_pause'),
command_builder=self.build_toggle_command,
summary_builder=self.build_toggle_summary,
action_id_builder=self.build_toggle_action_id,
label_builder=self.build_toggle_label,
)
def create_sync_template(self) -> TuiActionTemplate:
"""
创建任务同步动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='job-sync',
label=TUI_COPY.build_action_label('job_sync'),
command_builder=lambda record, env: ('job', 'sync'),
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('job_sync'),
TUI_COPY.build_action_purpose_label('job_sync'),
),
)
@@ -0,0 +1,385 @@
from dataclasses import dataclass
from cli.tui.actions.builders import TuiActionTemplate, TuiActionTemplateSupport
from cli.tui.adapters.models import BrowserRecordSnapshot
from cli.tui.copy import TUI_COPY
@dataclass(frozen=True)
class StaticActionTemplateFactory:
"""
静态页面动作模板工厂。
该对象负责生成不依赖当前记录内容的页面动作模板,统一详情页和
部分浏览页的固定动作定义。
:param support: 动作模板共享构建支持
"""
support: TuiActionTemplateSupport
@staticmethod
def build_job_sync_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建任务同步命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('job', 'sync')
@staticmethod
def build_config_sync_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建配置同步命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('config', 'sync-cache')
@staticmethod
def build_cache_warmup_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建缓存预热命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('cache', 'warmup')
@staticmethod
def build_db_upgrade_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建数据库升级向导命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record
return (
'wizard',
'db-upgrade',
'--output=text',
f'--default-env={env}',
'--default-revision=head',
'--default-dry-run',
)
@staticmethod
def build_db_init_dry_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建数据库初始化预演命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('db', 'init', '--dry-run')
@staticmethod
def build_app_run_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建应用启动向导命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('wizard', 'app-run')
@staticmethod
def build_completion_install_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建补全安装命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('completion', 'install', '--activate')
@staticmethod
def build_prod_check_wizard_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建生产巡检向导命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record
return (
'wizard',
'prod-check',
'--output=text',
f'--default-env={env}',
'--default-include-config',
)
@staticmethod
def build_crypto_rotate_dry_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建加密轮换预演命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('crypto', 'rotate', '--dry-run')
@staticmethod
def build_app_run_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建应用直接启动命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record
return ('app', 'run', f'--env={env}')
@staticmethod
def build_crypto_keygen_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建加密密钥生成命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record
return ('crypto', 'keygen', f'--env={env}', '--output=text')
@staticmethod
def build_ops_ping_db_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建数据库探活命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('ops', 'ping-db')
@staticmethod
def build_ops_ping_redis_command(record: BrowserRecordSnapshot | None, env: str) -> tuple[str, ...]:
"""
构建 Redis 探活命令参数。
:param record: 当前记录
:param env: 当前运行环境
:return: 命令参数
"""
del record, env
return ('ops', 'ping-redis')
def create_config_sync_template(self) -> TuiActionTemplate:
"""
创建配置同步动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='config-sync-cache',
label=TUI_COPY.build_action_label('config_sync'),
command_builder=self.build_config_sync_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('config_sync'),
TUI_COPY.build_action_purpose_label('config_sync'),
),
)
def create_db_upgrade_wizard_template(self) -> TuiActionTemplate:
"""
创建数据库升级向导动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='wizard-db-upgrade',
label=TUI_COPY.build_action_label('db_upgrade_wizard'),
command_builder=self.build_db_upgrade_wizard_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
'当前环境数据库升级流程',
'进入向导后确认目标 revision、环境和 dry-run 预演',
),
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
)
def create_db_init_dry_run_template(self) -> TuiActionTemplate:
"""
创建数据库初始化预演动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='db-init-dry-run',
label=TUI_COPY.build_action_label('db_init_dry_run'),
command_builder=self.build_db_init_dry_run_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('db_init_dry_run'),
TUI_COPY.build_action_purpose_label('db_init_dry_run'),
),
append_yes=False,
)
def create_app_run_wizard_template(self) -> TuiActionTemplate:
"""
创建应用启动向导动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='wizard-app-run',
label=TUI_COPY.build_action_label('app_run_wizard'),
command_builder=self.build_app_run_wizard_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
'当前环境应用启动流程',
'进入向导后确认环境,并决定是否先执行启动前检查',
),
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
refresh_view=False,
)
def create_completion_install_template(self) -> TuiActionTemplate:
"""
创建补全安装动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='completion-install',
label=TUI_COPY.build_action_label('completion_install'),
command_builder=self.build_completion_install_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('completion_install'),
TUI_COPY.build_action_purpose_label('completion_install'),
),
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('external'),
refresh_view=False,
preview_env_override='-',
)
def create_prod_check_wizard_template(self) -> TuiActionTemplate:
"""
创建生产巡检向导动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='wizard-prod-check',
label=TUI_COPY.build_action_label('prod_check_wizard'),
command_builder=self.build_prod_check_wizard_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
'当前环境生产巡检流程',
'进入向导后统一检查数据库、缓存和运行配置',
),
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
)
def create_crypto_rotate_dry_run_template(self) -> TuiActionTemplate:
"""
创建加密轮换预演动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='crypto-rotate-dry-run',
label=TUI_COPY.build_action_label('crypto_rotate_dry_run'),
command_builder=self.build_crypto_rotate_dry_run_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
'当前环境传输加密轮换预演',
'仅生成轮换辅助结果,不直接写入新密钥配置',
),
append_yes=False,
)
def create_app_run_template(self) -> TuiActionTemplate:
"""
创建应用直接启动动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='app-run',
label=TUI_COPY.build_action_label('app_run'),
command_builder=self.build_app_run_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
'当前环境应用直接启动流程',
'确认后会在当前终端直接启动当前 FastAPI 应用,并持续占用终端会话',
),
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
refresh_view=False,
)
def create_crypto_keygen_template(self) -> TuiActionTemplate:
"""
创建密钥生成动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='crypto-keygen',
label=TUI_COPY.build_action_label('crypto_keygen'),
command_builder=self.build_crypto_keygen_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
'当前环境新密钥生成流程',
'在终端中直接输出新密钥材料和环境变量补丁建议',
),
execution_mode='external',
consequence_text=TUI_COPY.build_action_consequence_text('wizard'),
refresh_view=False,
)
def create_ops_ping_db_template(self) -> TuiActionTemplate:
"""
创建数据库探活动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='ops-ping-db',
label=TUI_COPY.build_action_label('ops_ping_db'),
command_builder=self.build_ops_ping_db_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('ops_ping_db'),
TUI_COPY.build_action_purpose_label('ops_ping_db'),
),
append_yes=False,
)
def create_ops_ping_redis_template(self) -> TuiActionTemplate:
"""
创建 Redis 探活动作模板。
:return: 动作模板
"""
return TuiActionTemplate(
action_id='ops-ping-redis',
label=TUI_COPY.build_action_label('ops_ping_redis'),
command_builder=self.build_ops_ping_redis_command,
summary_builder=lambda record, env: self.support.build_scope_purpose_summary(
TUI_COPY.build_action_scope_label('ops_ping_redis'),
TUI_COPY.build_action_purpose_label('ops_ping_redis'),
),
append_yes=False,
)
@@ -0,0 +1,72 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
from cli.tui.adapters.models import TUI_ADAPTER_MODEL_RENDERER, BrowserRecordSnapshot
ActionSlot = Literal['primary', 'secondary', 'global', 'utility']
ActionExecutionMode = Literal['nested_json', 'external']
ActionCommandBuilder = Callable[[BrowserRecordSnapshot | None, str], tuple[str, ...] | None]
ActionSummaryBuilder = Callable[[BrowserRecordSnapshot | None, str], list[str]]
ActionTextBuilder = Callable[[BrowserRecordSnapshot | None, str], str]
@dataclass(frozen=True)
class TuiActionSpec:
"""
TUI 低风险动作定义。
:param action_id: 动作唯一标识
:param label: 动作显示名称
:param command_args: 对应 CLI 命令参数
:param preview_title: 预览弹窗标题
:param preview_lines: 预览摘要文本
:param append_yes: 执行 nested JSON 动作时是否自动追加 `--yes`
:param refresh_view: 执行完成后是否刷新当前页面
"""
action_id: str
label: str
command_args: tuple[str, ...]
preview_title: str
preview_lines: list[str]
execution_mode: ActionExecutionMode = 'nested_json'
append_yes: bool = True
refresh_view: bool = True
@dataclass(frozen=True)
class TuiActionResult:
"""
TUI 动作执行结果。
:param spec: 动作定义
:param payload: CLI JSON 负载
"""
spec: TuiActionSpec
payload: dict[str, object] | None = None
external_exit_code: int | None = None
external_message: str | None = None
@property
def ok(self) -> bool:
"""
判断动作是否执行成功。
:return: 是否成功
"""
if self.external_exit_code is not None:
return self.external_exit_code == 0
return bool(isinstance(self.payload, dict) and self.payload.get('ok', False))
@property
def message(self) -> str:
"""
获取动作结果摘要。
:return: 结果摘要
"""
if self.external_message is not None:
return self.external_message
return TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(self.payload)
@@ -0,0 +1,110 @@
from dataclasses import dataclass
from cli.tui.actions.models import TuiActionResult
from cli.tui.actions.registry import TuiActionRegistry
from cli.tui.adapters.models import BrowserRecordSnapshot
from cli.tui.capabilities import TuiCapabilityRegistry
from cli.tui.copy import TUI_COPY
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
from cli.utils import SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class TuiActionPresentationService:
"""
TUI 动作展示服务。
该对象负责动作提示、动作列表和动作结果通知等展示层文本构建,
避免继续保留模块级桥接函数。
"""
capability_registry: TuiCapabilityRegistry
action_registry: TuiActionRegistry
def build_browser_action_hint(self, view_key: str) -> str:
"""
构建浏览页动作提示文本。
:param view_key: 当前页面视图标识
:return: 动作提示文本
"""
capability_labels = [
f'[{capability.key}] {capability.hint_label}'
for capability in self.capability_registry.get_browser_capabilities(view_key)
]
interaction_text = TUI_COPY.build_browser_action_hint_text(
view_key, TUI_KEYMAP_REGISTRY.browser_interaction_hint
)
return TUI_COPY.build_capability_hint_text(
capability_labels,
interaction_text,
fallback='{interaction_hint}',
)
def build_detail_action_hint(self, view_key: str) -> str:
"""
构建详情页动作提示文本。
:param view_key: 当前页面视图标识
:return: 动作提示文本
"""
capability_labels = [
f'[{capability.key}] {capability.hint_label}'
for capability in self.capability_registry.get_detail_capabilities(view_key)
]
interaction_text = TUI_COPY.build_detail_action_hint_text(
view_key, TUI_KEYMAP_REGISTRY.browser_interaction_hint
)
return TUI_COPY.build_capability_hint_text(
capability_labels,
interaction_text,
fallback='{interaction_hint}',
)
def build_browser_action_lines(
self,
*,
view_key: str,
record: BrowserRecordSnapshot | None,
env: str,
) -> list[str]:
"""
构建当前页面可执行动作列表。
:param view_key: 当前页面视图标识
:param record: 当前选中记录
:param env: 当前运行环境
:return: 动作文本行
"""
lines: list[str] = []
for capability in self.capability_registry.get_browser_capabilities(view_key):
slot = capability.slot # type: ignore[assignment]
action = self.action_registry.resolve_browser_action(
view_key=view_key,
slot=slot,
record=record,
env=env,
)
if action is None:
continue
lines.append(TUI_COPY.build_action_line(capability.key, action.label))
return lines or [TUI_COPY.build_action_empty_line()]
@staticmethod
def build_action_result_message(result: TuiActionResult) -> str:
"""
构建动作执行结果通知文本。
:param result: 动作执行结果
:return: 通知文本
"""
outcome = (
TUI_COPY.build_action_result_field_label('success')
if result.ok
else TUI_COPY.build_action_result_field_label('fail')
)
return TUI_COPY.build_action_result_toast(
result.spec.label,
outcome,
SHELL_TEXT_FORMATTER.truncate_text(result.message, 72),
)
@@ -0,0 +1,103 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cli.tui.actions.builders import TuiActionSpecFactory, TuiActionTemplate
from cli.tui.actions.models import ActionSlot, TuiActionSpec
from cli.tui.adapters.models import BrowserRecordSnapshot
@dataclass(frozen=True)
class TuiActionRegistry:
"""
TUI 动作注册表。
该注册表负责集中维护浏览页和详情页的动作解析器,从而将
原本散落的条件分发收敛为可声明的映射关系。
:param browser_resolvers: 浏览页动作槽位解析器映射
:param detail_resolvers: 详情页动作槽位解析器映射
"""
browser_resolvers: dict[str, TuiActionSlotResolver]
detail_resolvers: dict[str, TuiActionSlotResolver]
def resolve_browser_action(
self,
*,
view_key: str,
slot: ActionSlot,
record: BrowserRecordSnapshot | None,
env: str,
) -> TuiActionSpec | None:
"""
解析浏览页动作。
:param view_key: 当前页面视图标识
:param slot: 动作槽位
:param record: 当前选中记录
:param env: 当前运行环境
:return: 动作定义
"""
resolver = self.browser_resolvers.get(view_key)
if resolver is None:
return None
return resolver.resolve(slot=slot, record=record, env=env)
def resolve_detail_action(
self,
*,
view_key: str,
slot: ActionSlot,
env: str,
) -> TuiActionSpec | None:
"""
解析详情页动作。
:param view_key: 当前页面视图标识
:param slot: 动作槽位
:param env: 当前运行环境
:return: 动作定义
"""
resolver = self.detail_resolvers.get(view_key)
if resolver is None:
return None
return resolver.resolve(slot=slot, record=None, env=env)
@dataclass(frozen=True)
class TuiActionSlotResolver:
"""
TUI 动作槽位解析器。
该对象将一个页面支持的槽位动作集中定义为映射关系,避免继续在
`resolve_*_action` 中堆叠条件分支。
:param slot_templates: 槽位到动作模板的映射
:param spec_factory: 动作规格构建器
"""
slot_templates: dict[ActionSlot, TuiActionTemplate]
spec_factory: TuiActionSpecFactory
def resolve(
self,
*,
slot: ActionSlot,
record: BrowserRecordSnapshot | None,
env: str,
) -> TuiActionSpec | None:
"""
解析指定槽位的动作定义。
:param slot: 动作槽位
:param record: 当前选中记录
:param env: 当前运行环境
:return: 动作定义
"""
template = self.slot_templates.get(slot)
if template is None:
return None
return template.build(record=record, env=env, spec_factory=self.spec_factory)
@@ -0,0 +1,98 @@
from collections.abc import Callable
from dataclasses import dataclass
from cli.tui.adapters.app import APP_DETAIL_ADAPTER as _APP_DETAIL_ADAPTER
from cli.tui.adapters.cache import CACHE_BROWSER_ADAPTER as _CACHE_BROWSER_ADAPTER
from cli.tui.adapters.configs import CONFIGS_BROWSER_ADAPTER as _CONFIGS_BROWSER_ADAPTER
from cli.tui.adapters.crypto import CRYPTO_DETAIL_ADAPTER as _CRYPTO_DETAIL_ADAPTER
from cli.tui.adapters.database import DATABASE_DETAIL_ADAPTER as _DATABASE_DETAIL_ADAPTER
from cli.tui.adapters.gen import GEN_BROWSER_ADAPTER as _GEN_BROWSER_ADAPTER
from cli.tui.adapters.health import (
DASHBOARD_ADAPTER as _DASHBOARD_ADAPTER,
)
from cli.tui.adapters.health import DashboardPanelSnapshot, DashboardSnapshot
from cli.tui.adapters.jobs import JOBS_BROWSER_ADAPTER as _JOBS_BROWSER_ADAPTER
from cli.tui.adapters.models import (
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailPageSnapshot,
DetailSectionSnapshot,
)
from cli.tui.adapters.ops import OPS_DETAIL_ADAPTER as _OPS_DETAIL_ADAPTER
PageSnapshot = BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot
SnapshotCollector = Callable[..., PageSnapshot]
@dataclass(frozen=True)
class TuiSnapshotCollectorRegistry:
"""
TUI 页面快照采集注册表。
该对象集中维护页面视图标识与其采集函数之间的映射,
使应用层和测试层可以通过统一入口发现当前可用的采集器。
:param collectors: 视图到采集函数的映射
"""
collectors: dict[str, SnapshotCollector]
@staticmethod
def normalize_view_key(view_key: str) -> str:
"""
规范化页面视图标识。
:param view_key: 原始页面视图标识
:return: 规范化后的视图标识
"""
return str(view_key).strip().lower()
def get_collector(self, view_key: str) -> SnapshotCollector | None:
"""
读取指定视图的快照采集器。
:param view_key: 页面视图标识
:return: 采集函数
"""
return self.collectors.get(self.normalize_view_key(view_key))
def collect(self, view_key: str, *args, **kwargs) -> PageSnapshot:
"""
调用指定视图的快照采集器。
:param view_key: 页面视图标识
:param args: 位置参数
:param kwargs: 关键字参数
:return: 页面快照
:raises KeyError: 当视图未注册时抛出
"""
collector = self.get_collector(view_key)
if collector is None:
raise KeyError(self.normalize_view_key(view_key))
return collector(*args, **kwargs)
TUI_SNAPSHOT_COLLECTOR_REGISTRY = TuiSnapshotCollectorRegistry(
collectors={
'dashboard': _DASHBOARD_ADAPTER.collect_snapshot,
'app': _APP_DETAIL_ADAPTER.collect_snapshot,
'ops': _OPS_DETAIL_ADAPTER.collect_snapshot,
'database': _DATABASE_DETAIL_ADAPTER.collect_snapshot,
'cache': _CACHE_BROWSER_ADAPTER.collect_snapshot,
'jobs': _JOBS_BROWSER_ADAPTER.collect_snapshot,
'gen': _GEN_BROWSER_ADAPTER.collect_snapshot,
'configs': _CONFIGS_BROWSER_ADAPTER.collect_snapshot,
'crypto': _CRYPTO_DETAIL_ADAPTER.collect_snapshot,
}
)
__all__ = [
'TUI_SNAPSHOT_COLLECTOR_REGISTRY',
'BrowserPageSnapshot',
'BrowserRecordSnapshot',
'DashboardPanelSnapshot',
'DashboardSnapshot',
'DetailPageSnapshot',
'DetailSectionSnapshot',
'TuiSnapshotCollectorRegistry',
]
@@ -0,0 +1,680 @@
from dataclasses import dataclass
from typing import Any
from cli.tui.adapters.base import BaseDetailAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
DetailPageSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
COMPLETION_PREVIEW_LINE_LIMIT = 12
@dataclass(frozen=True)
class AppDetailSourcePayloads:
"""
应用详情页原始数据源快照。
:param env_payload: `app env` 结果
:param config_payload: `app config` 结果
:param doctor_payload: `app doctor` 结果
:param routes_payload: `app routes` 结果
:param completion_payload: `completion doctor` 结果
:param completion_preview_result: `completion show` 命令结果
:param preview_shell: 当前补全脚本预览 shell
"""
env_payload: dict[str, Any] | None
config_payload: dict[str, Any] | None
doctor_payload: dict[str, Any] | None
routes_payload: dict[str, Any] | None
completion_payload: dict[str, Any] | None
completion_preview_result: Any
preview_shell: str
class AppDetailSnapshotCollector:
"""
应用详情页数据采集器。
该对象负责拉取应用详情页所需的多路 CLI 原始结果,
让 `AppDetailAdapter` 保持详情页编排职责。
:param section_builder: 应用详情分区构建器
"""
def __init__(self, section_builder: 'AppSectionBuilder') -> None:
"""
初始化应用详情页数据采集器。
:param section_builder: 应用详情分区构建器
:return: None
"""
self.section_builder = section_builder
def collect(self, env: str) -> AppDetailSourcePayloads:
"""
采集应用详情页所需原始结果。
:param env: 当前运行环境
:return: 应用详情页原始数据源快照
"""
env_payload = NESTED_CLI_SUPPORT.run(
'app',
'env',
f'--env={env}',
'--output=json',
parse_json=True,
).payload
config_payload = NESTED_CLI_SUPPORT.run(
'app',
'config',
f'--env={env}',
'--output=json',
parse_json=True,
).payload
doctor_payload = NESTED_CLI_SUPPORT.run(
'app',
'doctor',
f'--env={env}',
'--output=json',
parse_json=True,
).payload
routes_payload = NESTED_CLI_SUPPORT.run(
'app',
'routes',
f'--env={env}',
'--group-by=tag',
'--output=json',
parse_json=True,
).payload
completion_payload = NESTED_CLI_SUPPORT.run(
'completion',
'doctor',
'--output=json',
parse_json=True,
).payload
preview_shell = self.section_builder.resolve_completion_preview_shell(completion_payload)
completion_preview_result = NESTED_CLI_SUPPORT.run('completion', 'show', preview_shell)
return AppDetailSourcePayloads(
env_payload=env_payload,
config_payload=config_payload,
doctor_payload=doctor_payload,
routes_payload=routes_payload,
completion_payload=completion_payload,
completion_preview_result=completion_preview_result,
preview_shell=preview_shell,
)
class AppSectionBuilder:
"""
应用详情分区构建器。
该构建器负责将应用、补全和路由相关 CLI 结果转换为 TUI 详情页分区,
使详情页适配器本体只保留采集与编排职责。
"""
@staticmethod
def resolve_completion_preview_shell(payload: dict[str, Any] | None) -> str:
"""
解析补全脚本预览应使用的 shell。
:param payload: `completion doctor` JSON 负载
:return: shell 名称
"""
if not isinstance(payload, dict):
return 'bash'
active_shell = str(payload.get('activeShell') or '').strip().lower()
shells = payload.get('shells') if isinstance(payload.get('shells'), dict) else {}
if active_shell and isinstance(shells.get(active_shell), dict) and shells[active_shell].get('supported', False):
return active_shell
for shell_name in ('bash', 'zsh', 'fish', 'powershell'):
shell_payload = shells.get(shell_name)
if isinstance(shell_payload, dict) and shell_payload.get('supported', False):
return shell_name
return 'bash'
@staticmethod
def build_env_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建应用环境解析分区。
:param payload: `app env` JSON 负载
:return: 分区快照
"""
runtime = payload.get('runtime') if isinstance(payload, dict) else None
if not isinstance(runtime, dict):
return DetailSectionSnapshot(
title='环境解析',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='环境信息', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='环境解析',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 环境映射',
f'CLI 目标环境: {runtime.get("cliEnv", "-")}',
f'配置模块环境: {runtime.get("configEnv", "-")}',
f'进程环境变量: {runtime.get("appEnv", "-") or "-"}',
'',
'## 文件与进程',
f'环境文件: {runtime.get("envFile", "-")}',
f'环境文件存在: {"是" if runtime.get("envFileExists", False) else "否"}',
f'后端目录: {SHELL_TEXT_FORMATTER.truncate_text(runtime.get("backendDir", "-"), 64)}',
f'Python 可执行文件: {SHELL_TEXT_FORMATTER.truncate_text(runtime.get("pythonExecutable", "-"), 64)}',
],
)
@staticmethod
def build_config_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建应用基础配置分区。
:param payload: `app config` JSON 负载
:return: 分区快照
"""
config = payload.get('config') if isinstance(payload, dict) else None
if not isinstance(config, dict):
return DetailSectionSnapshot(
title='应用配置',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='应用配置', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='应用配置',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 应用入口',
f'应用名称: {SHELL_TEXT_FORMATTER.truncate_text(config.get("name", "-"), 40)}',
f'监听地址: {config.get("host", "-")}:{config.get("port", "-")}',
f'根路径: {config.get("rootPath", "-") or "/"}',
f'工作进程: {config.get("workers", "-")}',
f'热重载: {"开启" if config.get("reload", False) else "关闭"}',
'',
'## 文档与日志',
f'Swagger: {"关闭" if config.get("disableSwagger", False) else "开启"}',
f'ReDoc: {"关闭" if config.get("disableRedoc", False) else "开启"}',
f'日志级别: {config.get("logLevel", "-")}',
],
)
@staticmethod
def build_dependency_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建应用依赖配置分区。
:param payload: `app config` JSON 负载
:return: 分区快照
"""
config = payload.get('config') if isinstance(payload, dict) else None
if not isinstance(config, dict):
return DetailSectionSnapshot(
title='依赖配置',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='依赖配置', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='依赖配置',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 数据库',
f'类型: {config.get("dbType", "-")}',
f'地址: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
f'数据库名: {config.get("dbDatabase", "-")}',
'',
'## Redis 与加密',
f'Redis 地址: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}',
f'传输加密: {"开启" if config.get("transportCryptoEnabled", False) else "关闭"}',
f'加密模式: {config.get("transportCryptoMode", "-")}',
],
)
def build_routes_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建应用路由摘要分区。
:param payload: `app routes` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='路由摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='路由', empty_value='不可用'),
)
count = payload.get('count', 0)
routes = payload.get('routes') if isinstance(payload.get('routes'), list) else []
grouped_routes = payload.get('groupedRoutes') if isinstance(payload.get('groupedRoutes'), dict) else {}
group_items = sorted(
((str(tag), len(items) if isinstance(items, list) else 0) for tag, items in grouped_routes.items()),
key=lambda item: (-item[1], item[0]),
)
lines = [
'## 路由规模',
f'总路由数: {count}',
f'标签分组数: {len(group_items)}',
'',
'## 主要标签',
]
if group_items:
lines.extend(f'> {tag} · {size} 条' for tag, size in group_items[:6])
else:
lines.append('> 暂无标签分组数据')
if routes:
lines.extend(['', '## 路由样本'])
for route in routes[:6]:
if not isinstance(route, dict):
continue
methods = '/'.join(route.get('methods', [])) if isinstance(route.get('methods'), list) else '-'
lines.extend(
[
f'> [{methods or "-"}] {SHELL_TEXT_FORMATTER.truncate_text(route.get("path", "-"), 48)}',
f'> {SHELL_TEXT_FORMATTER.truncate_text(route.get("summary", "-") or "-", 48)}',
]
)
else:
lines.extend(
[
'',
*TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='路由样本',
empty_value='0 条',
detail='当前环境未返回可展示的路由样本',
suggestion=TUI_COPY.build_refresh_page_suggestion('应用', '确认当前环境是否已完成路由注册'),
),
]
)
return DetailSectionSnapshot(
title='路由摘要',
status='ok',
lines=lines,
)
@staticmethod
def build_completion_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建 shell completion 诊断分区。
:param payload: `completion doctor` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='补全诊断',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='补全诊断', empty_value='不可用'
),
)
shells = payload.get('shells') if isinstance(payload.get('shells'), dict) else {}
active_shell = str(payload.get('activeShell') or '-')
env_choices = payload.get('envChoices') if isinstance(payload.get('envChoices'), list) else []
active_shell_payload = shells.get(active_shell) if isinstance(shells.get(active_shell), dict) else None
target_file = active_shell_payload.get('targetFile', '-') if isinstance(active_shell_payload, dict) else '-'
source_command = (
active_shell_payload.get('sourceCommand', '-') if isinstance(active_shell_payload, dict) else '-'
)
install_command = (
active_shell_payload.get('recommendedInstallCommand', '-')
if isinstance(active_shell_payload, dict)
else payload.get('recommendedInstallCommand', '-')
)
lines = [
'## 当前环境',
f'活动 Shell: {active_shell}',
f'项目目录: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("projectDir", "-"), 64)}',
f'补全环境变量: {payload.get("completeEnvVar", "-")}',
f'环境候选数: {len(env_choices)}',
'',
'## 激活建议',
f'目标脚本: {SHELL_TEXT_FORMATTER.truncate_text(target_file, 64)}',
f'加载命令: {SHELL_TEXT_FORMATTER.truncate_text(source_command, 72)}',
f'推荐安装命令: {SHELL_TEXT_FORMATTER.truncate_text(install_command, 72)}',
]
shell_lines = []
for shell_name in ('bash', 'zsh', 'fish', 'powershell'):
shell_payload = shells.get(shell_name)
if not isinstance(shell_payload, dict):
continue
supported = '支持' if shell_payload.get('supported', False) else '不支持'
detected = '当前 Shell' if shell_payload.get('detected', False) else '候选 Shell'
shell_lines.append(f'> {shell_name} · {supported} · {detected}')
if shell_lines:
lines.extend(['', '## Shell 支持'])
lines.extend(shell_lines)
return DetailSectionSnapshot(
title='补全诊断',
status='ok',
lines=lines,
)
def build_completion_preview_section(
self,
shell: str,
preview_result: Any,
) -> DetailSectionSnapshot:
"""
构建补全脚本预览分区。
:param shell: 当前预览 shell
:param preview_result: `completion show` 调用结果
:return: 分区快照
"""
stdout = str(getattr(preview_result, 'stdout', '') or '').strip()
stderr = str(getattr(preview_result, 'stderr', '') or '').strip()
returncode = int(getattr(preview_result, 'returncode', 1) or 0)
if returncode != 0:
return DetailSectionSnapshot(
title='补全脚本预览',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
{'ok': False, 'message': stderr or stdout or '补全脚本生成失败'},
empty_label='补全脚本',
empty_value='不可用',
),
)
preview_lines = [
SHELL_TEXT_FORMATTER.truncate_text(line, 88) for line in stdout.splitlines()[:COMPLETION_PREVIEW_LINE_LIMIT]
]
if not preview_lines:
return DetailSectionSnapshot(
title='补全脚本预览',
status='info',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='补全脚本',
empty_value='0 行',
detail='当前 shell 的补全脚本未返回可预览内容',
suggestion=TUI_COPY.build_refresh_page_suggestion('应用', '重新生成补全脚本预览'),
),
)
return DetailSectionSnapshot(
title='补全脚本预览',
status='ok',
lines=[
'## 预览目标',
f'Shell: {shell}',
f'预览行数: {len(preview_lines)}',
'',
'## 脚本片段',
*preview_lines,
],
)
@staticmethod
def build_doctor_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建应用启动前检查分区。
:param payload: `app doctor` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict):
return DetailSectionSnapshot(
title='启动前检查',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='启动前检查', empty_value='不可用'
),
)
database = payload.get('database') if isinstance(payload.get('database'), dict) else {}
redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {}
crypto = payload.get('crypto') if isinstance(payload.get('crypto'), dict) else {}
return DetailSectionSnapshot(
title='启动前检查',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 检查结果',
f'环境: {payload.get("env", "-")}',
f'数据库: {"正常" if database.get("ok", False) else "异常"}',
f'> {SHELL_TEXT_FORMATTER.truncate_text(database.get("message", "-"), 64)}',
f'Redis: {"正常" if redis.get("ok", False) else "异常"}',
f'> {SHELL_TEXT_FORMATTER.truncate_text(redis.get("message", "-"), 64)}',
f'加密组件: {"正常" if crypto.get("ok", False) else "异常"}',
f'> {SHELL_TEXT_FORMATTER.truncate_text(crypto.get("message", "-"), 64)}',
],
)
@staticmethod
def build_run_entry_section() -> DetailSectionSnapshot:
"""
构建应用启动向导入口分区。
:return: 分区快照
"""
return DetailSectionSnapshot(
title='启动入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备启动当前环境应用时,可直接启动应用,或先通过向导确认目标环境与启动前检查选项。',
command=TUI_COPY.build_cli_command_hint('app', 'run', '--env=dev'),
guide='若需要先做启动前检查或切换环境,建议改用 `wizard app-run` 进入交互式启动流程。',
),
)
@staticmethod
def build_completion_install_entry_section() -> DetailSectionSnapshot:
"""
构建 shell 补全安装入口分区。
:return: 分区快照
"""
return DetailSectionSnapshot(
title='补全安装入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备为当前 Shell 安装或修复补全脚本时,可直接执行补全安装命令,并按需把激活命令写入 rc 文件。',
command=TUI_COPY.build_cli_command_hint('completion', 'install', '--activate', '--output=text'),
guide='当前页面提供补全诊断与安装入口;执行安装后可返回补全诊断分区确认 target file 和 source command 是否更新。',
),
)
@staticmethod
def build_overview_section(
env_payload: dict[str, Any] | None,
config_payload: dict[str, Any] | None,
doctor_payload: dict[str, Any] | None,
routes_payload: dict[str, Any] | None,
completion_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建应用页总览判断分区。
:param env_payload: `app env` JSON 负载
:param config_payload: `app config` JSON 负载
:param doctor_payload: `app doctor` JSON 负载
:param routes_payload: `app routes` JSON 负载
:param completion_payload: `completion doctor` JSON 负载
:return: 分区快照
"""
env_ok = bool(isinstance(env_payload, dict) and env_payload.get('ok', False))
config_ok = bool(isinstance(config_payload, dict) and config_payload.get('ok', False))
doctor_ok = bool(isinstance(doctor_payload, dict) and doctor_payload.get('ok', False))
routes_ok = bool(isinstance(routes_payload, dict) and routes_payload.get('ok', False))
completion_ok = bool(isinstance(completion_payload, dict) and completion_payload.get('ok', False))
route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0
runtime = (
env_payload.get('runtime')
if isinstance(env_payload, dict) and isinstance(env_payload.get('runtime'), dict)
else {}
)
config = (
config_payload.get('config')
if isinstance(config_payload, dict) and isinstance(config_payload.get('config'), dict)
else {}
)
status = 'ok'
conclusion = '应用基线正常,可继续查看环境映射、配置摘要与路由状态'
if not env_ok or not config_ok:
status = 'fail'
conclusion = '应用基础信息读取异常,优先确认环境映射与应用配置是否可用'
elif not doctor_ok:
status = 'fail'
conclusion = '启动前检查异常,优先确认数据库、Redis 和加密组件状态'
elif not completion_ok:
status = 'warn'
conclusion = '补全诊断读取异常,建议优先确认项目目录、Shell 类型和补全脚本状态'
elif not routes_ok:
status = 'warn'
conclusion = '路由摘要读取异常,建议先确认当前环境是否完成应用初始化'
elif int(route_count or 0) <= 0:
status = 'info'
conclusion = '当前未返回注册路由,建议检查路由注册流程与运行上下文'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'CLI 环境: {runtime.get("cliEnv", "-")}',
f'配置环境: {runtime.get("configEnv", "-")}',
f'监听地址: {config.get("host", "-")}:{config.get("port", "-")}',
f'注册路由: {route_count} 条',
f'启动前检查: {"正常" if doctor_ok else "异常"}',
f'传输加密: {"开启" if config.get("transportCryptoEnabled", False) else "关闭"}',
f'补全诊断: {"正常" if completion_ok else "异常"}',
'',
'## 建议入口',
'优先关注:环境解析 / 应用配置 / 启动前检查 / 补全诊断 / 路由摘要 / 启动入口',
],
)
def build_sections(
self,
*,
env_payload: dict[str, Any] | None,
config_payload: dict[str, Any] | None,
doctor_payload: dict[str, Any] | None,
routes_payload: dict[str, Any] | None,
completion_payload: dict[str, Any] | None,
completion_preview_result: Any,
preview_shell: str,
) -> list[DetailSectionSnapshot]:
"""
构建应用页全部详情分区。
:param env_payload: `app env` 结果负载
:param config_payload: `app config` 结果负载
:param doctor_payload: `app doctor` 结果负载
:param routes_payload: `app routes` 结果负载
:param completion_payload: `completion doctor` 结果负载
:param completion_preview_result: `completion show` 命令结果
:param preview_shell: 当前预览 shell
:return: 详情分区列表
"""
return [
self.build_overview_section(
env_payload,
config_payload,
doctor_payload,
routes_payload,
completion_payload,
),
self.build_env_section(env_payload),
self.build_config_section(config_payload),
self.build_dependency_section(config_payload),
self.build_doctor_section(doctor_payload),
self.build_completion_section(completion_payload),
self.build_completion_preview_section(preview_shell, completion_preview_result),
self.build_completion_install_entry_section(),
self.build_routes_section(routes_payload),
self.build_run_entry_section(),
]
class AppDetailAdapter(BaseDetailAdapter):
"""
应用详情页适配器。
该适配器负责采集应用相关 CLI 结果,并委托分区构建器组装为
TUI 详情页快照。
"""
def __init__(
self,
section_builder: AppSectionBuilder | None = None,
snapshot_collector: AppDetailSnapshotCollector | None = None,
) -> None:
"""
初始化应用详情页适配器。
:param section_builder: 应用详情分区构建器
:param snapshot_collector: 应用详情页数据采集器
:return: None
"""
super().__init__(
page_title='应用',
search_view_key='app',
default_suggestions=[
'总览判断',
'环境解析',
'应用配置',
'依赖配置',
'启动前检查',
'补全诊断',
'补全脚本预览',
'补全安装入口',
'路由摘要',
'启动入口',
],
)
self.section_builder = section_builder or AppSectionBuilder()
self.snapshot_collector = snapshot_collector or AppDetailSnapshotCollector(self.section_builder)
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
"""
采集应用状态页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 页面快照
"""
source_payloads = self.snapshot_collector.collect(env)
sections = self.section_builder.build_sections(
env_payload=source_payloads.env_payload,
config_payload=source_payloads.config_payload,
doctor_payload=source_payloads.doctor_payload,
routes_payload=source_payloads.routes_payload,
completion_payload=source_payloads.completion_payload,
completion_preview_result=source_payloads.completion_preview_result,
preview_shell=source_payloads.preview_shell,
)
return DetailPageSnapshot(
title='应用',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_app_diagnostic_subtitle(
source_payloads.env_payload,
source_payloads.config_payload,
source_payloads.doctor_payload,
source_payloads.routes_payload,
source_payloads.completion_payload,
),
sections=self.filter_sections(sections, query),
search=self.resolve_search_context(query),
)
APP_DETAIL_ADAPTER = AppDetailAdapter()
@@ -0,0 +1,219 @@
from collections.abc import Sequence
from typing import Any
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.search import TUI_SEARCH_SERVICE, PageFilterOption, PageSearchContext
from cli.utils import SHELL_TEXT_FORMATTER
class BaseBrowserAdapter:
"""
TUI 浏览页适配基类。
:param page_title: 浏览页标题
:param search_view_key: 搜索上下文对应的页面键
:param filter_options: 页面支持的筛选项
"""
def __init__(
self,
*,
page_title: str,
search_view_key: str,
filter_options: Sequence[PageFilterOption],
) -> None:
"""
初始化浏览页适配基类。
:param page_title: 浏览页标题
:param search_view_key: 搜索上下文对应的页面键
:param filter_options: 页面支持的筛选项
:return: None
"""
self.page_title = page_title
self.search_view_key = search_view_key
self.filter_options = tuple(filter_options)
@staticmethod
def extract_page_rows(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
从标准分页 JSON 负载中提取行数据。
:param payload: 标准分页结果负载
:return: 行数据列表
"""
page_payload = payload.get('page') if isinstance(payload, dict) else None
rows = page_payload.get('rows') if isinstance(page_payload, dict) else None
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def resolve_active_filter(self, filter_key: str) -> PageFilterOption:
"""
解析当前激活筛选项。
:param filter_key: 筛选键
:return: 已解析的筛选项
"""
return TUI_SEARCH_SERVICE.resolve_filter_option(self.filter_options, filter_key) or self.filter_options[0]
def resolve_search_context(self, query: str) -> PageSearchContext:
"""
解析当前页面搜索上下文。
:param query: 搜索词
:return: 搜索上下文
"""
return TUI_SEARCH_SERVICE.resolve_search_context(self.search_view_key, query)
def build_failure_record(
self,
*,
key: str,
subject: str,
section_subject: str,
payload: dict[str, Any] | None,
) -> BrowserRecordSnapshot:
"""
构建底层命令失败时的兜底记录。
:param key: 记录键
:param subject: 页面主体名称
:param section_subject: 失败分区名称
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return BrowserRecordSnapshot(
key=key,
title=TUI_COPY.build_unavailable_record_title(subject),
status='fail',
summary=SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(payload), 64),
metadata_lines=[],
detail_sections=[
DetailSectionSnapshot(
title=TUI_COPY.build_load_failure_section_title(section_subject),
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label=section_subject, empty_value='不可用'
),
)
],
)
def build_empty_record(
self,
*,
key: str,
subject: str,
empty_label: str,
has_source_rows: bool,
filtered_summary: str,
empty_summary: str,
filtered_empty_value: str,
empty_empty_value: str,
filtered_detail: str,
empty_detail: str,
) -> BrowserRecordSnapshot:
"""
构建当前页面没有可浏览记录时的空态记录。
:param key: 记录键
:param subject: 记录主体名称
:param empty_label: 空态主字段名称
:param has_source_rows: 是否存在未筛选前的源数据
:param filtered_summary: 有源数据但当前筛选无结果时的摘要
:param empty_summary: 源数据本身为空时的摘要
:param filtered_empty_value: 有源数据但当前筛选无结果时的空态值
:param empty_empty_value: 源数据本身为空时的空态值
:param filtered_detail: 有源数据但当前筛选无结果时的说明
:param empty_detail: 源数据本身为空时的说明
:return: 浏览记录快照
"""
summary = filtered_summary if has_source_rows else empty_summary
empty_value = filtered_empty_value if has_source_rows else empty_empty_value
detail = filtered_detail if has_source_rows else empty_detail
return BrowserRecordSnapshot(
key=key,
title=TUI_COPY.build_empty_record_title(subject),
status='info',
summary=TUI_COPY.build_empty_record_summary(summary),
metadata_lines=[],
detail_sections=[
DetailSectionSnapshot(
title=TUI_COPY.build_empty_record_title(subject),
status='info',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label=empty_label,
empty_value=empty_value,
detail=detail,
),
)
],
)
@staticmethod
def extract_payload_message(payload: dict[str, object] | None) -> str:
"""
提取结果负载中的可读消息。
:param payload: 标准结果负载
:return: 摘要消息文本
"""
return TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)
class BaseDetailAdapter:
"""
TUI 详情页适配基类。
:param page_title: 详情页标题
:param search_view_key: 搜索上下文对应的页面键
:param default_suggestions: 默认搜索建议
"""
def __init__(
self,
*,
page_title: str,
search_view_key: str,
default_suggestions: Sequence[str],
) -> None:
"""
初始化详情页适配基类。
:param page_title: 详情页标题
:param search_view_key: 搜索上下文对应的页面键
:param default_suggestions: 默认搜索建议
:return: None
"""
self.page_title = page_title
self.search_view_key = search_view_key
self.default_suggestions = tuple(default_suggestions)
def filter_sections(self, sections: Sequence[DetailSectionSnapshot], query: str) -> list[DetailSectionSnapshot]:
"""
按搜索词过滤详情分区列表。
:param sections: 原始详情分区列表
:param query: 搜索词
:return: 过滤后的分区列表
"""
return TUI_SEARCH_SERVICE.filter_detail_sections(list(sections), query) # type: ignore[arg-type]
def resolve_search_context(self, query: str) -> PageSearchContext:
"""
解析当前详情页搜索上下文。
:param query: 搜索词
:return: 搜索上下文
"""
return TUI_SEARCH_SERVICE.resolve_search_context(
self.search_view_key,
query,
default_suggestions=list(self.default_suggestions),
)
@@ -0,0 +1,579 @@
from typing import Any
from cli.tui.adapters.base import BaseBrowserAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
class CacheRowExtractor:
"""
缓存浏览行提取器。
该对象负责从缓存统计结果中提取缓存名称列表,供共享分区和记录构建
逻辑复用。
"""
@staticmethod
def extract_cache_name_rows(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
从缓存统计结果中提取缓存名称列表。
:param payload: `cache stats` JSON 负载
:return: 缓存名称行列表
"""
if not isinstance(payload, dict):
return []
cache_names = payload.get('cacheNames')
if not isinstance(cache_names, list):
return []
rows: list[dict[str, Any]] = []
for item in cache_names:
if isinstance(item, dict):
rows.append(item)
continue
if isinstance(item, str):
rows.append({'cacheName': item, 'remark': ''})
return rows
class CacheSectionBuilder:
"""
缓存浏览页分区构建器。
该构建器负责构建缓存浏览页共享分区,以及单个缓存名前缀下的键摘要、
键列表和键详情分区。
:param page_adapter: 缓存浏览页适配器
:param row_extractor: 缓存浏览行提取器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
row_extractor: CacheRowExtractor | None = None,
) -> None:
"""
初始化缓存浏览页分区构建器。
:param page_adapter: 缓存浏览页适配器
:param row_extractor: 缓存浏览行提取器
:return: None
"""
self.page_adapter = page_adapter
self.row_extractor = row_extractor or CacheRowExtractor()
def build_overview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建缓存总览共享分区。
:param payload: `cache stats` JSON 负载
:return: 分区快照
"""
info = payload.get('info') if isinstance(payload, dict) and isinstance(payload.get('info'), dict) else {}
cache_rows = self.row_extractor.extract_cache_name_rows(payload)
return DetailSectionSnapshot(
title='Redis 摘要',
status='ok' if isinstance(payload, dict) and payload.get('ok', False) else 'fail',
lines=[
'## 容量状态',
f'当前键数: {payload.get("dbSize", "-") if isinstance(payload, dict) else "-"}',
f'已登记缓存名: {len(cache_rows)} 个',
'',
'## 运行指标',
f'Redis 版本: {info.get("redis_version", "-")}',
f'客户端连接数: {info.get("connected_clients", "-")}',
f'内存占用: {info.get("used_memory_human", info.get("used_memory", "-"))}',
],
)
def build_overview_judgement_section(
self,
payload: dict[str, Any] | None,
filtered_rows: list[dict[str, Any]],
) -> DetailSectionSnapshot:
"""
构建缓存页总览判断共享分区。
:param payload: `cache stats` JSON 负载
:param filtered_rows: 当前筛选后的缓存名列表
:return: 分区快照
"""
info = payload.get('info') if isinstance(payload, dict) and isinstance(payload.get('info'), dict) else {}
cache_rows = self.row_extractor.extract_cache_name_rows(payload)
db_size = payload.get('dbSize', '-') if isinstance(payload, dict) else '-'
client_count = info.get('connected_clients', '-')
command_stats = payload.get('commandStats') if isinstance(payload, dict) else None
command_count = len(command_stats) if isinstance(command_stats, list) else 0
status = 'ok'
conclusion = '缓存基线正常,可继续查看键列表、键值样本与 TTL'
if not isinstance(payload, dict) or not payload.get('ok', False):
status = 'fail'
conclusion = '缓存状态读取失败,优先确认 Redis 连通性与运行环境'
elif not cache_rows:
status = 'info'
conclusion = '当前没有登记的缓存名前缀,可先核对缓存配置与实际键空间'
elif not filtered_rows:
status = 'info'
conclusion = '当前搜索条件没有命中缓存名前缀,可调整关键字后继续排查'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'已登记缓存名: {len(cache_rows)} 个',
f'当前匹配: {len(filtered_rows)} 个',
f'Redis 键数: {db_size}',
f'客户端连接数: {client_count}',
f'命令统计样本: {command_count} 组',
'',
'## 建议入口',
'优先关注:Redis 摘要 / 命令统计 / 键值样本 / TTL',
],
)
def build_top_commands_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建缓存命令统计共享分区。
:param payload: `cache stats` JSON 负载
:return: 分区快照
"""
command_stats = payload.get('commandStats') if isinstance(payload, dict) else None
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='命令统计',
empty_value='暂无数据',
detail='当前 Redis 运行快照中未返回命令统计样本',
)
section_status = 'info'
if isinstance(command_stats, list) and command_stats:
section_status = 'ok'
lines = [
f'{item.get("name", "-")} · {item.get("value", 0)} 次'
for item in command_stats[:8]
if isinstance(item, dict)
]
return DetailSectionSnapshot(
title='命令统计',
status=section_status if isinstance(payload, dict) and payload.get('ok', False) else 'fail',
lines=lines,
)
@staticmethod
def build_cache_clear_entry_section() -> DetailSectionSnapshot:
"""
构建缓存清理向导入口分区。
:return: 分区快照
"""
return DetailSectionSnapshot(
title='缓存清理入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='当缓存命中异常、键数量失控,或需要按缓存名和前缀清理时,应先通过向导确认影响范围。',
command=TUI_COPY.build_cli_command_hint('wizard', 'cache-clear', '--output=text'),
guide='向导会继续询问环境、缓存名、键前缀和 dry-run 选项,确认后再执行实际清理。',
),
)
def build_keys_summary_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建缓存键摘要分区。
:param payload: `cache keys` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='键摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='键摘要', empty_value='不可用'
),
)
keys = payload.get('keys') if isinstance(payload.get('keys'), list) else []
rendered_keys = [str(item).strip() for item in keys if str(item).strip()]
lines = [
'## 统计',
f'缓存名: {payload.get("cacheName", "-")}',
f'键数量: {payload.get("count", len(rendered_keys))}',
]
if rendered_keys:
lines.extend(
['', '## 键样本', *[f'> {SHELL_TEXT_FORMATTER.truncate_text(item, 64)}' for item in rendered_keys[:5]]]
)
else:
lines.extend(['', '## 键样本', '> 当前缓存名前缀下没有键'])
return DetailSectionSnapshot(
title='键摘要',
status='ok',
lines=lines,
)
def build_keys_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建缓存键列表分区。
:param payload: `cache keys` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='键列表',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='键列表', empty_value='不可用'
),
)
keys = payload.get('keys') if isinstance(payload.get('keys'), list) else []
lines = [
SHELL_TEXT_FORMATTER.truncate_text(item, 88) for item in keys[:12] if str(item).strip()
] or TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='缓存键',
empty_value='0 个',
detail='当前缓存名前缀下没有可展示的缓存键',
)
return DetailSectionSnapshot(
title='键列表',
status='ok',
lines=lines,
)
def render_ttl_text(self, payload: dict[str, Any] | None) -> str:
"""
渲染缓存 TTL 文本。
:param payload: `cache ttl` JSON 负载
:return: TTL 文本
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(payload), 56)
ttl_seconds = payload.get('ttlSeconds', '-')
if payload.get('persistent', False):
return '永久'
if payload.get('expires', False):
return f'{ttl_seconds} 秒'
return str(ttl_seconds)
def build_key_detail_sections(
self,
cache_name: str,
key_items: list[str],
env: str,
) -> list[DetailSectionSnapshot]:
"""
构建按缓存键展开的详情分区。
:param cache_name: 缓存名称
:param key_items: 缓存键列表
:param env: 当前运行环境
:return: 分区快照列表
"""
if not key_items:
return []
sections: list[DetailSectionSnapshot] = []
for cache_key in key_items[:5]:
value_payload = NESTED_CLI_SUPPORT.run(
'cache',
'get',
cache_name,
cache_key,
f'--env={env}',
'--output=json',
parse_json=True,
).payload
ttl_payload = NESTED_CLI_SUPPORT.run(
'cache',
'ttl',
cache_name,
cache_key,
f'--env={env}',
'--output=json',
parse_json=True,
).payload
value_ok = isinstance(value_payload, dict) and value_payload.get('ok', False)
ttl_ok = isinstance(ttl_payload, dict) and ttl_payload.get('ok', False)
if value_ok and ttl_ok:
section_status = 'ok'
elif value_ok or ttl_ok:
section_status = 'warn'
else:
section_status = 'fail'
cache_value = (
'' if not value_ok or value_payload.get('cacheValue') is None else str(value_payload.get('cacheValue'))
)
preview_lines = cache_value.splitlines()[:6]
ttl_text = self.render_ttl_text(ttl_payload)
lines = [
'## 键信息',
f'缓存名: {cache_name}',
f'缓存键: {SHELL_TEXT_FORMATTER.truncate_text(cache_key, 72)}',
f'TTL: {ttl_text}',
'',
'## 值预览',
]
if value_ok:
lines.extend(
[f'> {SHELL_TEXT_FORMATTER.truncate_text(line, 72)}' for line in preview_lines]
if preview_lines
else ['> -']
)
else:
lines.append(
f'> {SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(value_payload), 72)}'
)
if not ttl_ok:
lines.extend(
[
'',
'## TTL 结果',
f'> {SHELL_TEXT_FORMATTER.truncate_text(self.page_adapter.extract_payload_message(ttl_payload), 72)}',
]
)
sections.append(
DetailSectionSnapshot(
title=f'键详情 · {SHELL_TEXT_FORMATTER.truncate_text(cache_key, 28)}',
status=section_status,
lines=lines,
)
)
return sections
def load_cache_detail_sections(
self,
cache_row: dict[str, Any],
env: str,
) -> list[DetailSectionSnapshot]:
"""
按需加载单个缓存名称的详情分区。
:param cache_row: 缓存名称行数据
:param env: 当前运行环境
:return: 详情分区列表
"""
cache_name = str(cache_row.get('cacheName', '-') or '-')
keys_payload = NESTED_CLI_SUPPORT.run(
'cache',
'keys',
cache_name,
f'--env={env}',
'--output=json',
parse_json=True,
).payload
key_items: list[str] = []
if isinstance(keys_payload, dict) and keys_payload.get('ok', False):
raw_keys = keys_payload.get('keys') if isinstance(keys_payload.get('keys'), list) else []
key_items = [str(item).strip() for item in raw_keys if str(item).strip()]
return [
self.build_keys_summary_section(keys_payload),
self.build_keys_section(keys_payload),
*self.build_key_detail_sections(cache_name, key_items, env),
]
def build_shared_sections(
self,
payload: dict[str, Any] | None,
filtered_rows: list[dict[str, Any]],
) -> list[DetailSectionSnapshot]:
"""
构建缓存浏览页共享分区。
:param payload: `cache stats` 结果负载
:param filtered_rows: 当前筛选后的缓存名列表
:return: 共享分区列表
"""
return [
self.build_overview_judgement_section(payload, filtered_rows),
self.build_overview_section(payload),
self.build_top_commands_section(payload),
self.build_cache_clear_entry_section(),
]
class CacheRecordBuilder:
"""
缓存浏览记录构建器。
该对象负责构建缓存页单条浏览记录与失败兜底记录。
:param page_adapter: 缓存浏览页适配器
:param section_builder: 缓存浏览页分区构建器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
section_builder: CacheSectionBuilder,
) -> None:
"""
初始化缓存浏览记录构建器。
:param page_adapter: 缓存浏览页适配器
:param section_builder: 缓存浏览页分区构建器
:return: None
"""
self.page_adapter = page_adapter
self.section_builder = section_builder
def build_record(self, cache_row: dict[str, Any], env: str) -> BrowserRecordSnapshot:
"""
构建单条缓存浏览记录。
:param cache_row: 缓存名称行数据
:param env: 当前运行环境
:return: 浏览记录快照
"""
cache_name = str(cache_row.get('cacheName', '-') or '-')
remark = str(cache_row.get('remark', '') or '').strip()
return BrowserRecordSnapshot(
key=f'cache:{cache_name}',
title=SHELL_TEXT_FORMATTER.truncate_text(cache_name, 40),
status='ok',
summary=SHELL_TEXT_FORMATTER.truncate_text(remark or '查看当前缓存名前缀下的键、值样本和过期时间', 64),
metadata_lines=[
'## 缓存身份',
f'缓存名称: {cache_name}',
f'用途说明: {SHELL_TEXT_FORMATTER.truncate_text(remark or "-", 64)}',
],
detail_sections=[],
detail_loader=lambda cache_row=cache_row, env=env: self.section_builder.load_cache_detail_sections(
cache_row, env
),
)
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
"""
构建缓存页失败兜底记录。
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return self.page_adapter.build_failure_record(
key='cache:unavailable',
subject='缓存',
section_subject='缓存状态',
payload=payload,
)
class CacheBrowserAdapter(BaseBrowserAdapter):
"""
缓存浏览页适配器。
该适配器负责采集 Redis 统计和缓存名前缀列表,并委托协作对象构建
共享分区、单条记录与键详情分区。
"""
def __init__(
self,
row_extractor: CacheRowExtractor | None = None,
section_builder: CacheSectionBuilder | None = None,
record_builder: CacheRecordBuilder | None = None,
) -> None:
"""
初始化缓存浏览页适配器。
:param row_extractor: 缓存浏览行提取器
:param section_builder: 缓存浏览页分区构建器
:param record_builder: 缓存浏览记录构建器
:return: None
"""
super().__init__(
page_title='缓存',
search_view_key='cache',
filter_options=(),
)
self.row_extractor = row_extractor or CacheRowExtractor()
self.section_builder = section_builder or CacheSectionBuilder(self, self.row_extractor)
self.record_builder = record_builder or CacheRecordBuilder(self, self.section_builder)
@staticmethod
def apply_cache_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""
按缓存名或备注查询词过滤缓存行数据。
:param rows: 原始缓存行列表
:param query: 当前搜索词
:return: 过滤后的缓存行列表
"""
normalized_query = str(query).strip().lower()
if not normalized_query:
return rows
return [
row
for row in rows
if normalized_query in str(row.get('cacheName', '') or '').strip().lower()
or normalized_query in str(row.get('remark', '') or '').strip().lower()
]
def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot:
"""
采集缓存浏览页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 浏览页快照
"""
search_context = self.resolve_search_context(query)
payload = NESTED_CLI_SUPPORT.run(
'cache',
'stats',
f'--env={env}',
'--output=json',
parse_json=True,
).payload
cache_rows = self.row_extractor.extract_cache_name_rows(payload)
filtered_rows = self.apply_cache_query(cache_rows, query)
shared_sections = self.section_builder.build_shared_sections(payload, filtered_rows)
if not isinstance(payload, dict) or not payload.get('ok', False):
return BrowserPageSnapshot(
title='缓存',
subtitle=TUI_COPY.build_unavailable_subtitle(
'缓存状态',
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(payload), 72),
),
records=[self.record_builder.build_failure_record(payload)],
shared_sections=shared_sections,
search=search_context,
)
records = [self.record_builder.build_record(cache_row, env) for cache_row in filtered_rows[:12]]
if not records:
records = [
self.build_empty_record(
key='cache:none',
subject='缓存名',
empty_label='缓存名前缀',
has_source_rows=bool(cache_rows),
filtered_summary='当前搜索条件下没有匹配缓存名前缀',
empty_summary='当前运行环境没有登记可浏览的缓存名前缀',
filtered_empty_value='暂无匹配',
empty_empty_value='暂无登记',
filtered_detail='当前搜索条件下没有匹配缓存名前缀',
empty_detail='当前运行环境没有登记可浏览的缓存名前缀',
)
]
return BrowserPageSnapshot(
title='缓存',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_cache_diagnostic_subtitle(payload, len(filtered_rows)),
records=records,
shared_sections=shared_sections,
search=search_context,
)
CACHE_BROWSER_ADAPTER = CacheBrowserAdapter()
@@ -0,0 +1,760 @@
from typing import Any
from cli.tui.adapters.base import BaseBrowserAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.tui.search import CONFIG_FILTER_OPTIONS
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
CONFIG_RISK_PRIORITY = {
'mismatch': 0,
'missing-cache': 1,
'orphan-cache': 2,
'normal': 3,
}
CONFIG_RISK_LABELS = {
'mismatch': '值不一致',
'missing-cache': '缓存缺失',
'orphan-cache': '缓存孤立',
'normal': '正常',
}
CONFIG_RISK_STATUSES = {
'mismatch': 'fail',
'missing-cache': 'warn',
'orphan-cache': 'warn',
'normal': 'ok',
}
class ConfigRiskSupport:
"""
参数配置风险分类支持对象。
该对象负责从巡检结果提取风险集合、解析单项风险分类,以及执行
风险排序和筛选逻辑,供浏览页分区和记录构建复用。
"""
@staticmethod
def extract_config_issue_keys(payload: dict[str, Any] | None, field_name: str) -> set[str]:
"""
从配置巡检结果中提取指定风险字段对应的配置键集合。
:param payload: `config doctor` JSON 负载
:param field_name: 风险字段名
:return: 配置键集合
"""
items = payload.get(field_name) if isinstance(payload, dict) else None
if not isinstance(items, list):
return set()
return {item.strip() for item in items if isinstance(item, str) and item.strip()}
def build_config_risk_sets(self, payload: dict[str, Any] | None) -> dict[str, set[str]]:
"""
构建配置风险分类集合。
:param payload: `config doctor` JSON 负载
:return: 按风险分类聚合的配置键集合
"""
return {
'mismatch': self.extract_config_issue_keys(payload, 'mismatchKeys'),
'missing-cache': self.extract_config_issue_keys(payload, 'missingInCache'),
'orphan-cache': self.extract_config_issue_keys(payload, 'orphanInCache'),
}
@staticmethod
def resolve_config_risk_key(config_key: str, risk_sets: dict[str, set[str]]) -> str:
"""
解析单个配置项的风险分类。
:param config_key: 配置键
:param risk_sets: 风险集合
:return: 风险分类键
"""
normalized_key = str(config_key).strip()
if normalized_key in risk_sets.get('mismatch', set()):
return 'mismatch'
if normalized_key in risk_sets.get('missing-cache', set()):
return 'missing-cache'
if normalized_key in risk_sets.get('orphan-cache', set()):
return 'orphan-cache'
return 'normal'
def build_config_sort_key(
self,
config_row: dict[str, Any],
risk_sets: dict[str, set[str]],
) -> tuple[int, str]:
"""
构建配置列表排序键。
:param config_row: 配置项行数据
:param risk_sets: 风险集合
:return: 排序键
"""
config_key = str(config_row.get('configKey', '') or '').strip()
risk_key = self.resolve_config_risk_key(config_key, risk_sets)
return (CONFIG_RISK_PRIORITY.get(risk_key, 99), config_key)
def apply_config_filter(
self,
rows: list[dict[str, Any]],
risk_sets: dict[str, set[str]],
filter_key: str,
) -> list[dict[str, Any]]:
"""
按筛选键过滤配置行数据。
:param rows: 原始配置行列表
:param risk_sets: 风险集合
:param filter_key: 当前筛选键
:return: 过滤后的配置行列表
"""
normalized_filter = str(filter_key).strip().lower()
if normalized_filter == 'risky':
return [
row
for row in rows
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal'
]
if normalized_filter == 'mismatch':
return [
row
for row in rows
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) == 'mismatch'
]
if normalized_filter == 'cache-drift':
return [
row
for row in rows
if self.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets)
in {'missing-cache', 'orphan-cache'}
]
return rows
@staticmethod
def apply_config_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""
按配置键或配置名称查询词过滤配置行数据。
:param rows: 原始配置行列表
:param query: 当前搜索词
:return: 过滤后的配置行列表
"""
normalized_query = str(query).strip().lower()
if not normalized_query:
return rows
return [
row
for row in rows
if normalized_query in str(row.get('configKey', '') or '').strip().lower()
or normalized_query in str(row.get('configName', '') or '').strip().lower()
]
@staticmethod
def append_orphan_cache_rows(rows: list[dict[str, Any]], orphan_keys: set[str]) -> list[dict[str, Any]]:
"""
将仅存在于缓存侧的孤立配置补充为可浏览记录。
:param rows: 原始数据库配置行
:param orphan_keys: 缓存孤立配置键集合
:return: 补齐后的配置行列表
"""
merged_rows = list(rows)
existing_keys = {str(row.get('configKey', '') or '').strip() for row in rows}
merged_rows.extend(
{
'configId': '-',
'configKey': orphan_key,
'configName': '缓存孤立配置',
'configType': '-',
'configValue': '缓存侧残留,建议排查来源',
}
for orphan_key in sorted(orphan_keys - existing_keys)
)
return merged_rows
class ConfigSectionBuilder:
"""
参数配置浏览页分区构建器。
该构建器负责构建参数配置页共享分区,以及单个配置项详情分区。
:param page_adapter: 参数配置浏览页适配器
:param risk_support: 参数配置风险分类支持对象
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
risk_support: ConfigRiskSupport,
) -> None:
"""
初始化参数配置浏览页分区构建器。
:param page_adapter: 参数配置浏览页适配器
:param risk_support: 参数配置风险分类支持对象
:return: None
"""
self.page_adapter = page_adapter
self.risk_support = risk_support
def build_config_doctor_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建参数配置巡检共享分区。
:param payload: `config doctor` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='配置巡检',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='巡检结果', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='配置巡检',
status='ok',
lines=[
'## 一致性状态',
f'数据库配置: {payload.get("databaseCount", "-")} 项',
f'缓存同步配置: {payload.get("cacheCount", "-")} 项',
f'缓存缺失: {payload.get("missingInCacheCount", "-")} 项',
f'缓存孤立: {payload.get("orphanInCacheCount", "-")} 项',
f'值不一致: {payload.get("mismatchCount", "-")} 项',
],
)
def build_config_issue_samples_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建参数配置不一致样本共享分区。
:param payload: `config doctor` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='异常样本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='样本', empty_value='不可用'),
)
lines: list[str] = []
missing_in_cache = payload.get('missingInCache')
orphan_in_cache = payload.get('orphanInCache')
mismatch_keys = payload.get('mismatchKeys')
if isinstance(missing_in_cache, list) and missing_in_cache:
lines.extend(
f'缓存缺失示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
for item in missing_in_cache[:5]
if isinstance(item, str)
)
if isinstance(orphan_in_cache, list) and orphan_in_cache:
lines.extend(
f'缓存孤立示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
for item in orphan_in_cache[:5]
if isinstance(item, str)
)
if isinstance(mismatch_keys, list) and mismatch_keys:
lines.extend(
f'值不一致示例:{SHELL_TEXT_FORMATTER.truncate_text(item, 64)}'
for item in mismatch_keys[:5]
if isinstance(item, str)
)
issue_count = (
int(payload.get('missingInCacheCount', 0))
+ int(payload.get('orphanInCacheCount', 0))
+ int(payload.get('mismatchCount', 0))
)
return DetailSectionSnapshot(
title='异常样本',
status='fail' if issue_count > 0 else 'ok',
lines=lines
if lines
else TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='异常样本',
empty_value='0 条',
detail='当前未发现配置异常样本',
),
)
def build_config_set_entry_section(self, rows: list[dict[str, Any]]) -> DetailSectionSnapshot:
"""
构建配置变更入口共享分区。
:param rows: 当前配置行列表
:return: 分区快照
"""
sample_key = ''
sample_value = ''
for row in rows:
config_key = str(row.get('configKey', '') or '').strip()
config_value = str(row.get('configValue', '') or '').strip()
if config_key:
sample_key = config_key
sample_value = config_value or 'new-value'
break
if not sample_key:
sample_key = 'demo.config.key'
sample_value = 'new-value'
return DetailSectionSnapshot(
title='配置变更入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备修复高风险配置、回填缓存缺失项或校正数据库与缓存值不一致时,应先在终端中明确目标键和值,再执行配置变更命令。',
command=TUI_COPY.build_cli_command_hint('config', 'set', sample_key, sample_value, '--output=text'),
guide='建议先在当前页面核对配置键、来源和现值;变更完成后再回到工作台执行参数缓存刷新,确认巡检结果恢复正常。',
),
)
def build_high_risk_config_section(
self,
rows: list[dict[str, Any]],
risk_sets: dict[str, set[str]],
) -> DetailSectionSnapshot:
"""
构建高风险配置共享分区。
:param rows: 配置行列表
:param risk_sets: 风险集合
:return: 分区快照
"""
risky_rows = [
row
for row in sorted(
rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets)
)
if self.risk_support.resolve_config_risk_key(str(row.get('configKey', '') or ''), risk_sets) != 'normal'
]
if not risky_rows:
return DetailSectionSnapshot(
title='高风险配置',
status='ok',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='高风险配置',
empty_value='0 项',
detail='当前巡检结果中没有需要优先处理的高风险配置',
),
)
lines: list[str] = []
for row in risky_rows[:8]:
config_key = str(row.get('configKey', '-') or '-')
config_name = str(row.get('configName', '-') or '-')
risk_key = self.risk_support.resolve_config_risk_key(config_key, risk_sets)
lines.append(
f'[{CONFIG_RISK_LABELS.get(risk_key, "风险")}] '
f'{SHELL_TEXT_FORMATTER.truncate_text(config_key, 40)} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 20)}'
)
return DetailSectionSnapshot(
title='高风险配置',
status='warn' if risky_rows else 'ok',
lines=lines,
)
def build_configs_overview_section(
self,
rows: list[dict[str, Any]],
filtered_rows: list[dict[str, Any]],
risk_sets: dict[str, set[str]],
filter_label: str,
) -> DetailSectionSnapshot:
"""
构建参数配置页总览判断共享分区。
:param rows: 原始配置行列表
:param filtered_rows: 当前筛选后的配置行列表
:param risk_sets: 风险集合
:param filter_label: 当前筛选标签
:return: 分区快照
"""
mismatch_count = len(risk_sets.get('mismatch', set()))
drift_count = len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set()))
risky_count = mismatch_count + drift_count
status = 'ok'
conclusion = '当前配置一致性正常,可继续抽查单项详情与同步状态'
if mismatch_count > 0:
status = 'warn'
conclusion = '存在值不一致配置,优先核对数据库与缓存是否同步'
elif drift_count > 0:
status = 'warn'
conclusion = '存在缓存漂移配置,建议优先确认缺失项与孤立项来源'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'当前筛选: {filter_label}',
f'已加载配置: {len(rows)} 项',
f'当前匹配: {len(filtered_rows)} 项',
f'高风险配置: {risky_count} 项',
f'值不一致: {mismatch_count} 项',
f'缓存漂移: {drift_count} 项',
'',
'## 建议入口',
'优先关注:高风险配置 / 值不一致 / 缓存漂移',
],
)
def build_config_consistency_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建单个配置的一致性分区。
:param payload: `config get` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='同步状态',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='同步状态', empty_value='不可用'
),
)
lines = [
'## 当前配置',
f'键名: {payload.get("key", "-")}',
f'读取来源: {payload.get("source", "-")}',
]
source = str(payload.get('source', '-') or '-').strip().lower()
section_status = 'ok'
if source == 'both':
lines.append(f'数据库与缓存一致: {"是" if payload.get("inSync", False) else "否"}')
if not payload.get('inSync', False):
section_status = 'fail'
elif source == 'database':
lines.append('缓存状态: 缺失')
section_status = 'warn'
elif source == 'cache':
lines.append('数据库状态: 缺失')
section_status = 'warn'
return DetailSectionSnapshot(
title='同步状态',
status=section_status,
lines=lines,
)
def build_config_source_section(
self,
title: str,
config_item: dict[str, Any] | None,
*,
missing_text: str,
) -> DetailSectionSnapshot:
"""
构建配置来源详情分区。
:param title: 分区标题
:param config_item: 配置来源详情
:param missing_text: 缺失时提示
:return: 分区快照
"""
if not isinstance(config_item, dict):
return DetailSectionSnapshot(
title=title,
status='info',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label=title,
empty_value='未找到',
detail=missing_text,
),
)
return DetailSectionSnapshot(
title=title,
status='ok',
lines=[
'## 基础信息',
f'配置 ID: {config_item.get("configId", "-")}',
f'键名: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configKey", "-"), 48)}',
f'名称: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configName", "-"), 32)}',
f'键值: {SHELL_TEXT_FORMATTER.truncate_text(config_item.get("configValue", "-"), 72)}',
f'内置标记: {config_item.get("configType", "-")}',
'',
'## 备注',
SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', '-') or '-', 88),
],
)
def load_config_detail_sections(
self,
config_row: dict[str, Any],
env: str,
) -> list[DetailSectionSnapshot]:
"""
按需加载单个配置项详情分区。
:param config_row: 配置项行数据
:param env: 当前运行环境
:return: 详情分区列表
"""
config_key = str(config_row.get('configKey', '-') or '-')
detail_payload = NESTED_CLI_SUPPORT.run(
'config',
'get',
config_key,
f'--env={env}',
'--source=both',
'--output=json',
parse_json=True,
).payload
database_payload = detail_payload.get('database') if isinstance(detail_payload, dict) else None
cache_payload = detail_payload.get('cache') if isinstance(detail_payload, dict) else None
return [
self.build_config_consistency_section(detail_payload),
self.build_config_source_section('数据库配置', database_payload, missing_text='数据库中未找到该配置项'),
self.build_config_source_section('缓存配置', cache_payload, missing_text='缓存中未找到该配置项'),
]
class ConfigRecordBuilder:
"""
参数配置浏览记录构建器。
该构建器负责构建配置浏览记录与失败兜底记录。
:param page_adapter: 参数配置浏览页适配器
:param section_builder: 参数配置浏览页分区构建器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
section_builder: ConfigSectionBuilder,
) -> None:
"""
初始化参数配置浏览记录构建器。
:param page_adapter: 参数配置浏览页适配器
:param section_builder: 参数配置浏览页分区构建器
:return: None
"""
self.page_adapter = page_adapter
self.section_builder = section_builder
def build_record(
self,
config_row: dict[str, Any],
env: str,
*,
risk_key: str,
) -> BrowserRecordSnapshot:
"""
构建单条参数配置浏览记录。
:param config_row: 配置项行数据
:param env: 当前运行环境
:param risk_key: 当前配置项风险键
:return: 浏览记录快照
"""
config_id = config_row.get('configId', '-')
config_key = str(config_row.get('configKey', '-') or '-')
config_name = str(config_row.get('configName', '-') or '-')
config_type = str(config_row.get('configType', '-') or '-')
config_value = str(config_row.get('configValue', '-') or '-')
risk_label = CONFIG_RISK_LABELS.get(risk_key, '正常')
return BrowserRecordSnapshot(
key=f'config:{config_key}',
title=SHELL_TEXT_FORMATTER.truncate_text(config_key, 44),
status=CONFIG_RISK_STATUSES.get(risk_key, 'ok'),
summary=f'{risk_label} · {SHELL_TEXT_FORMATTER.truncate_text(config_name, 18)} · 值 {SHELL_TEXT_FORMATTER.truncate_text(config_value, 24)}',
metadata_lines=[
'## 配置身份',
f'配置 ID: {config_id}',
f'配置名称: {SHELL_TEXT_FORMATTER.truncate_text(config_name, 40)}',
f'系统内置: {config_type}',
f'风险分类: {risk_label}',
'',
'## 当前值',
SHELL_TEXT_FORMATTER.truncate_text(config_value, 88),
],
detail_sections=[],
detail_loader=lambda config_row=config_row, env=env: self.section_builder.load_config_detail_sections(
config_row,
env,
),
)
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
"""
构建配置页失败兜底记录。
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return self.page_adapter.build_failure_record(
key='config:unavailable',
subject='参数配置',
section_subject='配置列表',
payload=payload,
)
class ConfigsBrowserAdapter(BaseBrowserAdapter):
"""
参数配置浏览页适配器。
该适配器负责采集配置巡检、配置列表和单项配置详情,并统一完成
风险分类、筛选排序、共享分区构建与浏览记录装配。
:param risk_support: 参数配置风险分类支持对象
:param section_builder: 参数配置浏览页分区构建器
:param record_builder: 参数配置浏览记录构建器
"""
def __init__(
self,
risk_support: ConfigRiskSupport | None = None,
section_builder: ConfigSectionBuilder | None = None,
record_builder: ConfigRecordBuilder | None = None,
) -> None:
"""
初始化参数配置浏览页适配器。
:param risk_support: 参数配置风险分类支持对象
:param section_builder: 参数配置浏览页分区构建器
:param record_builder: 参数配置浏览记录构建器
:return: None
"""
super().__init__(
page_title='参数配置',
search_view_key='configs',
filter_options=CONFIG_FILTER_OPTIONS,
)
self.risk_support = risk_support or ConfigRiskSupport()
self.section_builder = section_builder or ConfigSectionBuilder(self, self.risk_support)
self.record_builder = record_builder or ConfigRecordBuilder(self, self.section_builder)
def collect_snapshot(self, env: str, filter_key: str = 'all', query: str = '') -> BrowserPageSnapshot:
"""
采集参数配置浏览页只读快照。
:param env: 当前运行环境
:param filter_key: 当前筛选键
:param query: 当前搜索词
:return: 浏览页快照
"""
active_filter_option = self.resolve_active_filter(filter_key)
active_filter = active_filter_option.key
active_filter_label = active_filter_option.label
search_context = self.resolve_search_context(query)
doctor_payload = NESTED_CLI_SUPPORT.run(
'config',
'doctor',
f'--env={env}',
'--sample-limit=5',
'--output=json',
parse_json=True,
).payload
list_payload = NESTED_CLI_SUPPORT.run(
'config',
'list',
f'--env={env}',
'--paged',
'--page-size=8',
'--output=json',
parse_json=True,
).payload
risk_sets = self.risk_support.build_config_risk_sets(doctor_payload)
if not isinstance(list_payload, dict) or not list_payload.get('ok', False):
return BrowserPageSnapshot(
title='参数配置',
subtitle=TUI_COPY.build_unavailable_subtitle(
'配置',
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(list_payload), 72),
),
records=[self.record_builder.build_failure_record(list_payload)],
shared_sections=[
self.section_builder.build_configs_overview_section([], [], risk_sets, active_filter_label),
self.section_builder.build_config_doctor_section(doctor_payload),
self.section_builder.build_high_risk_config_section([], risk_sets),
self.section_builder.build_config_issue_samples_section(doctor_payload),
self.section_builder.build_config_set_entry_section([]),
],
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
rows = self.risk_support.append_orphan_cache_rows(
self.extract_page_rows(list_payload),
risk_sets.get('orphan-cache', set()),
)
sorted_rows = sorted(
rows, key=lambda config_row: self.risk_support.build_config_sort_key(config_row, risk_sets)
)
filtered_rows = self.risk_support.apply_config_query(
self.risk_support.apply_config_filter(sorted_rows, risk_sets, active_filter),
query,
)
records = [
self.record_builder.build_record(
config_row,
env,
risk_key=self.risk_support.resolve_config_risk_key(
str(config_row.get('configKey', '') or ''),
risk_sets,
),
)
for config_row in filtered_rows[:12]
]
if not records:
records = [
self.build_empty_record(
key='config:none',
subject='配置项',
empty_label='配置项',
has_source_rows=bool(sorted_rows),
filtered_summary='当前筛选条件下没有匹配配置项',
empty_summary='当前环境没有可浏览的参数配置项',
filtered_empty_value='暂无数据',
empty_empty_value='暂无配置',
filtered_detail='当前筛选条件下没有匹配配置项',
empty_detail='当前环境没有可浏览的参数配置项',
)
]
subtitle = TUI_DIAGNOSTIC_SERVICE.build_configs_diagnostic_subtitle(
active_filter_label,
len(filtered_rows),
len(risk_sets.get('mismatch', set())),
len(risk_sets.get('missing-cache', set())) + len(risk_sets.get('orphan-cache', set())),
)
if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False):
subtitle = (
f'{subtitle} | {SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(doctor_payload), 72)}'
)
shared_sections = [
self.section_builder.build_configs_overview_section(rows, filtered_rows, risk_sets, active_filter_label),
self.section_builder.build_config_doctor_section(doctor_payload),
self.section_builder.build_high_risk_config_section(rows, risk_sets),
self.section_builder.build_config_issue_samples_section(doctor_payload),
self.section_builder.build_config_set_entry_section(rows),
]
return BrowserPageSnapshot(
title='参数配置',
subtitle=subtitle,
records=records,
shared_sections=shared_sections,
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
CONFIGS_BROWSER_ADAPTER = ConfigsBrowserAdapter()
@@ -0,0 +1,372 @@
from dataclasses import dataclass
from typing import Any
from cli.tui.adapters.base import BaseDetailAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
DetailPageSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class CryptoDetailSourcePayloads:
"""
传输加密详情页原始数据源快照。
:param validate_payload: `crypto validate` 结果
:param public_payload: `crypto export-public` 结果
"""
validate_payload: dict[str, Any] | None
public_payload: dict[str, Any] | None
class CryptoDetailSnapshotCollector:
"""
传输加密详情页数据采集器。
该对象负责拉取传输加密详情页所需的 CLI 原始结果,
让 `CryptoDetailAdapter` 保持详情页编排职责。
"""
def collect(self, env: str) -> CryptoDetailSourcePayloads:
"""
采集传输加密详情页所需原始结果。
:param env: 当前运行环境
:return: 传输加密详情页原始数据源快照
"""
return CryptoDetailSourcePayloads(
validate_payload=NESTED_CLI_SUPPORT.run(
'crypto',
'validate',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
public_payload=NESTED_CLI_SUPPORT.run(
'crypto',
'export-public',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
)
class CryptoSectionBuilder:
"""
传输加密详情分区构建器。
该构建器负责将传输加密相关 CLI 结果负载转换为 TUI 详情页分区,
使详情页适配器本体只保留采集与编排职责。
"""
@staticmethod
def build_validate_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建传输加密运行校验分区。
:param payload: `crypto validate` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict):
return DetailSectionSnapshot(
title='运行校验',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='运行校验', empty_value='不可用'
),
)
lines = [
'## 校验结果',
f'状态: {"通过" if payload.get("ok", False) else "失败"}',
f'说明: {SHELL_TEXT_FORMATTER.truncate_text(TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload), 72)}',
]
if payload.get('error'):
lines.append(f'错误: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("error", "-"), 72)}')
return DetailSectionSnapshot(
title='运行校验',
status='ok' if payload.get('ok', False) else 'fail',
lines=lines,
)
@staticmethod
def build_public_identity_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建公钥身份分区。
:param payload: `crypto export-public` JSON 负载
:return: 分区快照
"""
public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None
if not isinstance(public_key_payload, dict):
return DetailSectionSnapshot(
title='公钥身份',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='公钥', empty_value='不可用'),
)
return DetailSectionSnapshot(
title='公钥身份',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 当前版本',
f'KID: {public_key_payload.get("kid", "-")}',
f'算法: {public_key_payload.get("alg", "-")}',
f'信封版本: {public_key_payload.get("envelopeVersion", "-")}',
f'过期时间: {public_key_payload.get("expireAt", "-")}',
],
)
@staticmethod
def build_supported_kids_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建兼容版本分区。
:param payload: `crypto export-public` JSON 负载
:return: 分区快照
"""
public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None
if not isinstance(public_key_payload, dict):
return DetailSectionSnapshot(
title='兼容版本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='兼容版本', empty_value='不可用'
),
)
supported_kids = public_key_payload.get('supportedKids')
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='兼容版本',
empty_value='0 个',
detail='当前公钥未声明兼容版本 KID',
suggestion='如需兼容旧版本客户端,可检查 supportedKids 配置',
)
if isinstance(supported_kids, list) and supported_kids:
lines = ['## 兼容 KID', *[f'支持版本: {kid}' for kid in supported_kids[:10]]]
return DetailSectionSnapshot(
title='兼容版本',
status='ok',
lines=lines,
)
def build_public_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建公钥预览分区。
:param payload: `crypto export-public` JSON 负载
:return: 分区快照
"""
public_key_payload = payload.get('publicKey') if isinstance(payload, dict) else None
if not isinstance(public_key_payload, dict):
return DetailSectionSnapshot(
title='公钥预览',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='公钥预览', empty_value='不可用'
),
)
public_key = str(public_key_payload.get('publicKey', '') or '').splitlines()
if not public_key:
return DetailSectionSnapshot(
title='公钥预览',
status='info',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='公钥预览',
empty_value='无内容',
detail='当前公钥内容为空,无法生成预览',
suggestion='可检查公钥导出结果,或重新生成公钥后再刷新',
),
)
return DetailSectionSnapshot(
title='公钥预览',
status='info',
lines=['## 预览内容', *[SHELL_TEXT_FORMATTER.truncate_text(line, 88) for line in public_key[:6]]],
)
@staticmethod
def build_overview_section(
validate_payload: dict[str, Any] | None,
public_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建加密页总览判断分区。
:param validate_payload: `crypto validate` JSON 负载
:param public_payload: `crypto export-public` JSON 负载
:return: 分区快照
"""
validate_ok = bool(isinstance(validate_payload, dict) and validate_payload.get('ok', False))
public_ok = bool(isinstance(public_payload, dict) and public_payload.get('ok', False))
public_key_payload = public_payload.get('publicKey') if isinstance(public_payload, dict) else None
supported_kids = public_key_payload.get('supportedKids') if isinstance(public_key_payload, dict) else None
supported_count = len(supported_kids) if isinstance(supported_kids, list) else 0
status = 'ok'
conclusion = '传输加密基线正常,可继续查看公钥身份、兼容版本与预演入口'
if not validate_ok:
status = 'fail'
conclusion = '运行校验失败,优先确认传输加密配置与环境变量'
elif not public_ok or not isinstance(public_key_payload, dict):
status = 'warn'
conclusion = '公钥导出结果异常,建议先确认当前 KID 与公钥内容是否可用'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'运行校验: {"通过" if validate_ok else "失败"}',
f'当前 KID: {public_key_payload.get("kid", "-") if isinstance(public_key_payload, dict) else "-"}',
f'算法: {public_key_payload.get("alg", "-") if isinstance(public_key_payload, dict) else "-"}',
f'兼容版本数: {supported_count}',
'',
'## 建议入口',
'优先关注:运行校验 / 公钥身份 / 兼容版本 / 轮换预演入口',
],
)
@staticmethod
def build_rotation_entry_section(env: str) -> DetailSectionSnapshot:
"""
构建加密密钥轮换预演入口分区。
:param env: 当前运行环境
:return: 分区快照
"""
return DetailSectionSnapshot(
title='轮换预演入口',
status='warn',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备轮换传输加密密钥时,应先做 dry-run,确认新 KID、密钥长度和 guard 提示后再执行正式轮换。',
command=TUI_COPY.build_cli_command_hint(
'crypto',
'rotate',
f'--env={env}',
'--dry-run',
'--output=json',
),
guide='当前页面先提供预演入口;正式轮换仍应遵循危险命令确认流程,避免直接在未知环境落地变更。',
),
)
@staticmethod
def build_keygen_entry_section(env: str) -> DetailSectionSnapshot:
"""
构建加密密钥生成入口分区。
:param env: 当前运行环境
:return: 分区快照
"""
return DetailSectionSnapshot(
title='密钥生成入口',
status='warn',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备生成新的传输加密密钥对时,应在当前终端直接查看生成结果,并立即核对 KID、密钥长度和环境变量补丁。',
command=TUI_COPY.build_cli_command_hint(
'crypto',
'keygen',
f'--env={env}',
'--output=text',
),
guide='当前入口会在终端中直接输出新公钥、私钥和 env patch keys,后续再决定是否进入轮换流程。',
),
)
def build_sections(
self,
*,
validate_payload: dict[str, Any] | None,
public_payload: dict[str, Any] | None,
env: str,
) -> list[DetailSectionSnapshot]:
"""
构建传输加密页全部详情分区。
:param validate_payload: `crypto validate` 结果负载
:param public_payload: `crypto export-public` 结果负载
:param env: 当前运行环境
:return: 详情分区列表
"""
return [
self.build_overview_section(validate_payload, public_payload),
self.build_validate_section(validate_payload),
self.build_public_identity_section(public_payload),
self.build_supported_kids_section(public_payload),
self.build_public_preview_section(public_payload),
self.build_keygen_entry_section(env),
self.build_rotation_entry_section(env),
]
class CryptoDetailAdapter(BaseDetailAdapter):
"""
传输加密详情页适配器。
该适配器负责采集传输加密相关 CLI 结果,并组装为 TUI 详情页快照。
页面私有的解析逻辑、分区构建逻辑和快照采集流程统一收口在该类中。
:param section_builder: 传输加密详情分区构建器
"""
def __init__(
self,
section_builder: CryptoSectionBuilder | None = None,
snapshot_collector: CryptoDetailSnapshotCollector | None = None,
) -> None:
"""
初始化传输加密详情页适配器。
:param section_builder: 传输加密详情分区构建器
:param snapshot_collector: 传输加密详情页数据采集器
:return: None
"""
super().__init__(
page_title='传输加密',
search_view_key='crypto',
default_suggestions=[
'总览判断',
'运行校验',
'公钥身份',
'兼容版本',
'公钥预览',
'密钥生成入口',
'轮换预演入口',
],
)
self.section_builder = section_builder or CryptoSectionBuilder()
self.snapshot_collector = snapshot_collector or CryptoDetailSnapshotCollector()
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
"""
采集传输加密状态页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 页面快照
"""
source_payloads = self.snapshot_collector.collect(env)
sections = self.section_builder.build_sections(
validate_payload=source_payloads.validate_payload,
public_payload=source_payloads.public_payload,
env=env,
)
return DetailPageSnapshot(
title='传输加密',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_crypto_diagnostic_subtitle(
source_payloads.validate_payload,
source_payloads.public_payload,
),
sections=self.filter_sections(sections, query),
search=self.resolve_search_context(query),
)
CRYPTO_DETAIL_ADAPTER = CryptoDetailAdapter()
@@ -0,0 +1,474 @@
from dataclasses import dataclass
from typing import Any
from cli.tui.adapters.base import BaseDetailAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
DetailPageSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class DatabaseDetailSourcePayloads:
"""
数据库详情页原始数据源快照。
:param revision_payload: `db current` 结果
:param check_payload: `db check` 结果
:param heads_payload: `db heads` 结果
:param history_payload: `db history` 结果
:param config_payload: `app config` 结果
"""
revision_payload: dict[str, Any] | None
check_payload: dict[str, Any] | None
heads_payload: dict[str, Any] | None
history_payload: dict[str, Any] | None
config_payload: dict[str, Any] | None
class DatabaseDetailSnapshotCollector:
"""
数据库详情页数据采集器。
该对象负责拉取数据库详情页所需的多路 CLI 原始结果,
让 `DatabaseDetailAdapter` 保持详情页编排职责。
"""
def collect(self, env: str) -> DatabaseDetailSourcePayloads:
"""
采集数据库详情页所需原始结果。
:param env: 当前运行环境
:return: 数据库详情页原始数据源快照
"""
return DatabaseDetailSourcePayloads(
revision_payload=NESTED_CLI_SUPPORT.run(
'db',
'current',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
check_payload=NESTED_CLI_SUPPORT.run(
'db',
'check',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
heads_payload=NESTED_CLI_SUPPORT.run(
'db',
'heads',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
history_payload=NESTED_CLI_SUPPORT.run(
'db',
'history',
f'--env={env}',
'--limit=8',
'--output=json',
parse_json=True,
).payload,
config_payload=NESTED_CLI_SUPPORT.run(
'app',
'config',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
)
class DatabaseSectionBuilder:
"""
数据库详情分区构建器。
该构建器负责将数据库相关 CLI 结果负载转换为 TUI 详情页分区,
使详情页适配器本体只保留采集与编排职责。
"""
@staticmethod
def extract_revision_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
从 Alembic 结果中提取修订版本列表。
:param payload: `db heads` 或 `db history` JSON 负载
:return: 修订版本列表
"""
items = payload.get('items') if isinstance(payload, dict) else None
if not isinstance(items, list):
return []
return [item for item in items if isinstance(item, dict)]
@staticmethod
def build_revision_item_lines(item: dict[str, Any], *, index: int, item_label: str) -> list[str]:
"""
构建单条 Alembic 修订版本详情文本。
:param item: 修订版本字典
:param index: 当前索引
:param item_label: 条目标识名称
:return: 文本行列表
"""
down_revisions = item.get('downRevisions')
branch_labels = item.get('branchLabels')
depends_on = item.get('dependsOn')
return [
f'## {item_label} {index:02d} · {item.get("revision", "-")}',
f'> 下游版本: {",".join(down_revisions) if isinstance(down_revisions, list) and down_revisions else "-"}',
f'> 分支标签: {",".join(branch_labels) if isinstance(branch_labels, list) and branch_labels else "-"}',
f'> 依赖版本: {",".join(depends_on) if isinstance(depends_on, list) and depends_on else "-"}',
f'> 说明: {SHELL_TEXT_FORMATTER.truncate_text(item.get("doc", "-"), 72)}',
f'> 文件: {SHELL_TEXT_FORMATTER.truncate_text(item.get("path", "-"), 72)}',
]
@staticmethod
def build_revision_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建数据库迁移状态分区。
:param payload: `db current` JSON 负载
:return: 分区快照
"""
current_revision = payload.get('currentRevision', '-') if isinstance(payload, dict) else '-'
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='迁移版本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='迁移版本', empty_value=str(current_revision or '-')
),
)
return DetailSectionSnapshot(
title='迁移版本',
status='ok',
lines=[
'## 当前版本',
f'迁移版本: {current_revision}',
'',
'## 状态说明',
f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}',
],
)
def build_overview_section(
self,
revision_payload: dict[str, Any] | None,
check_payload: dict[str, Any] | None,
heads_payload: dict[str, Any] | None,
history_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建数据库页总览判断分区。
:param revision_payload: `db current` JSON 负载
:param check_payload: `db check` JSON 负载
:param heads_payload: `db heads` JSON 负载
:param history_payload: `db history` JSON 负载
:return: 分区快照
"""
revision_ok = bool(isinstance(revision_payload, dict) and revision_payload.get('ok', False))
check_ok = bool(isinstance(check_payload, dict) and check_payload.get('ok', False))
heads_ok = bool(isinstance(heads_payload, dict) and heads_payload.get('ok', False))
current_revision = revision_payload.get('currentRevision', '-') if isinstance(revision_payload, dict) else '-'
head_items = self.extract_revision_items(heads_payload)
history_items = self.extract_revision_items(history_payload)
status = 'ok'
conclusion = '数据库基线正常,可继续查看连接状态、Heads 与版本链路'
if not revision_ok or not check_ok:
status = 'fail'
conclusion = '数据库存在基础异常,优先确认迁移版本与连通性检查结果'
elif not heads_ok or len(head_items) != 1:
status = 'warn'
conclusion = '数据库存在迁移分叉风险,优先确认 Heads 与版本链路是否一致'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'当前 revision: {current_revision}',
f'连通性检查: {"正常" if check_ok else "异常"}',
f'Heads 数量: {len(head_items)}',
f'版本数量: {history_payload.get("totalCount", len(history_items)) if isinstance(history_payload, dict) else len(history_items)}',
'',
'## 建议入口',
'优先关注:迁移版本 / 连通性检查 / Heads 状态 / 升级入口',
],
)
@staticmethod
def build_profile_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建数据库连接配置分区。
:param payload: `app config` JSON 负载
:return: 分区快照
"""
config = payload.get('config') if isinstance(payload, dict) else None
if not isinstance(config, dict):
return DetailSectionSnapshot(
title='连接信息',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='连接信息', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='连接信息',
status='ok',
lines=[
'## 数据库连接',
f'数据库类型: {config.get("dbType", "-")}',
f'连接地址: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
f'数据库名: {config.get("dbDatabase", "-")}',
],
)
@staticmethod
def build_check_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建数据库连通性检查分区。
:param payload: `db check` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='连通性检查',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='数据库连接', empty_value='异常'
),
)
return DetailSectionSnapshot(
title='连通性检查',
status='ok',
lines=[
'## 当前状态',
'数据库连接: 正常',
'',
'## 结果摘要',
f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}',
],
)
def build_heads_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建 Alembic heads 分区。
:param payload: `db heads` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='Heads 状态',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='Heads', empty_value='不可用'
),
)
items = self.extract_revision_items(payload)
status = 'ok' if len(items) == 1 else 'warn'
lines = [
'## Heads 概况',
f'Heads 数量: {len(items)}',
f'结果消息: {TUI_ADAPTER_MODEL_RENDERER.extract_payload_message(payload)}',
]
if not items:
lines.extend(['', '## Heads 列表', '> 当前仓库未返回可用 heads'])
else:
lines.extend(['', '## Heads 列表'])
for index, item in enumerate(items[:4], start=1):
lines.extend(self.build_revision_item_lines(item, index=index, item_label='Head'))
lines.append('')
if len(lines) > 1 and lines[-1] == '':
lines.pop()
return DetailSectionSnapshot(
title='Heads 状态',
status=status,
lines=lines,
)
def build_history_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建 Alembic 历史版本分区。
:param payload: `db history` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='历史版本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='历史版本', empty_value='不可用'
),
)
items = self.extract_revision_items(payload)
lines = [
'## 历史概况',
f'返回数量: {payload.get("count", len(items))}',
f'总版本数: {payload.get("totalCount", len(items))}',
f'查询上限: {payload.get("limit", len(items))}',
'',
'## 历史列表',
]
if not items:
lines.append('> 当前仓库未返回可展示的历史版本')
else:
for index, item in enumerate(items[:6], start=1):
lines.extend(self.build_revision_item_lines(item, index=index, item_label='版本'))
lines.append('')
if lines[-1] == '':
lines.pop()
return DetailSectionSnapshot(
title='历史版本',
status='ok',
lines=lines,
)
@staticmethod
def build_upgrade_entry_section() -> DetailSectionSnapshot:
"""
构建数据库升级向导入口分区。
:return: 分区快照
"""
return DetailSectionSnapshot(
title='升级入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备执行数据库升级、校验迁移路径或在生产前做升级预演时,应先通过向导确认目标版本与 dry-run 结果。',
command=TUI_COPY.build_cli_command_hint('wizard', 'db-upgrade', '--output=text'),
guide='向导会继续询问目标环境和目标 revision,并在真正升级前先展示预览信息。',
),
)
@staticmethod
def build_init_entry_section() -> DetailSectionSnapshot:
"""
构建数据库初始化预演入口分区。
:return: 分区快照
"""
return DetailSectionSnapshot(
title='初始化预演入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备初始化新库、重建本地环境,或先确认初始化命令会执行到哪些迁移版本时,应先执行 dry-run 预演。',
command=TUI_COPY.build_cli_command_hint('db', 'init', '--dry-run', '--output=text'),
guide='预演只返回命令摘要与执行目录;确认无误后,再通过外部终端或向导执行真实初始化流程。',
),
)
def build_sections(
self,
*,
revision_payload: dict[str, Any] | None,
check_payload: dict[str, Any] | None,
heads_payload: dict[str, Any] | None,
history_payload: dict[str, Any] | None,
config_payload: dict[str, Any] | None,
) -> list[DetailSectionSnapshot]:
"""
构建数据库页全部详情分区。
:param revision_payload: `db current` 结果负载
:param check_payload: `db check` 结果负载
:param heads_payload: `db heads` 结果负载
:param history_payload: `db history` 结果负载
:param config_payload: `app config` 结果负载
:return: 详情分区列表
"""
return [
self.build_overview_section(revision_payload, check_payload, heads_payload, history_payload),
self.build_revision_section(revision_payload),
self.build_profile_section(config_payload),
self.build_check_section(check_payload),
self.build_heads_section(heads_payload),
self.build_history_section(history_payload),
self.build_init_entry_section(),
self.build_upgrade_entry_section(),
]
class DatabaseDetailAdapter(BaseDetailAdapter):
"""
数据库详情页适配器。
该适配器负责采集数据库相关 CLI 结果,并组装为 TUI 详情页快照。
页面私有的解析逻辑、分区构建逻辑和快照采集流程统一收口在该类中。
:param section_builder: 数据库详情分区构建器
"""
def __init__(
self,
section_builder: DatabaseSectionBuilder | None = None,
snapshot_collector: DatabaseDetailSnapshotCollector | None = None,
) -> None:
"""
初始化数据库详情页适配器。
:param section_builder: 数据库详情分区构建器
:param snapshot_collector: 数据库详情页数据采集器
:return: None
"""
super().__init__(
page_title='数据库',
search_view_key='database',
default_suggestions=[
'总览判断',
'迁移版本',
'连接信息',
'连通性检查',
'Heads 状态',
'历史版本',
'初始化预演入口',
'升级入口',
],
)
self.section_builder = section_builder or DatabaseSectionBuilder()
self.snapshot_collector = snapshot_collector or DatabaseDetailSnapshotCollector()
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
"""
采集数据库状态页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 页面快照
"""
source_payloads = self.snapshot_collector.collect(env)
sections = self.section_builder.build_sections(
revision_payload=source_payloads.revision_payload,
check_payload=source_payloads.check_payload,
heads_payload=source_payloads.heads_payload,
history_payload=source_payloads.history_payload,
config_payload=source_payloads.config_payload,
)
return DetailPageSnapshot(
title='数据库',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_database_diagnostic_subtitle(
source_payloads.revision_payload,
source_payloads.check_payload,
source_payloads.heads_payload,
),
sections=self.filter_sections(sections, query),
search=self.resolve_search_context(query),
)
DATABASE_DETAIL_ADAPTER = DatabaseDetailAdapter()
@@ -0,0 +1,807 @@
from typing import Any
from cli.tui.adapters.base import BaseBrowserAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
class GenRenderingSupport:
"""
代码生成浏览页渲染支持对象。
该对象负责布尔语义渲染与常见文本辅助逻辑,供分区和记录构建复用。
"""
@staticmethod
def render_yes_no(value: object) -> str:
"""
将常见布尔语义值转换为“是/否”。
:param value: 原始值
:return: 中文布尔文本
"""
normalized = str(value).strip().lower()
return '是' if normalized in {'1', 'y', 'yes', 'true'} else '否'
class GenSectionBuilder:
"""
代码生成浏览页分区构建器。
该构建器负责构建代码生成页共享分区,以及单条业务表详情分区。
:param page_adapter: 代码生成浏览页适配器
:param rendering: 代码生成浏览页渲染支持对象
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
rendering: GenRenderingSupport,
) -> None:
"""
初始化代码生成浏览页分区构建器。
:param page_adapter: 代码生成浏览页适配器
:param rendering: 代码生成浏览页渲染支持对象
:return: None
"""
self.page_adapter = page_adapter
self.rendering = rendering
def build_gen_focus_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建业务表概览分区。
:param payload: `gen detail` JSON 负载
:return: 分区快照
"""
detail = payload.get('detail') if isinstance(payload, dict) else None
info = detail.get('info') if isinstance(detail, dict) else None
if not isinstance(info, dict):
return DetailSectionSnapshot(
title='业务表摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='详情', empty_value='无'),
)
return DetailSectionSnapshot(
title='业务表摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 表身份',
f'表 ID: {payload.get("tableId", "-")}',
f'表名: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("tableName", "-"), 40)}',
],
)
def build_gen_generation_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建代码生成配置分区。
:param payload: `gen detail` JSON 负载
:return: 分区快照
"""
detail = payload.get('detail') if isinstance(payload, dict) else None
info = detail.get('info') if isinstance(detail, dict) else None
if not isinstance(info, dict):
return DetailSectionSnapshot(
title='生成配置',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='生成配置', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='生成配置',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 生成类信息',
f'生成类名: {SHELL_TEXT_FORMATTER.truncate_text(info.get("className", "-"), 32)}',
f'所属模块: {SHELL_TEXT_FORMATTER.truncate_text(info.get("moduleName", "-"), 24)}',
'',
'## 业务信息',
f'业务标识: {SHELL_TEXT_FORMATTER.truncate_text(info.get("businessName", "-"), 24)}',
f'功能名称: {SHELL_TEXT_FORMATTER.truncate_text(info.get("functionName", "-"), 24)}',
],
)
def build_gen_column_summary_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建字段概览分区。
:param payload: `gen detail` JSON 负载
:return: 分区快照
"""
detail = payload.get('detail') if isinstance(payload, dict) else None
rows = detail.get('rows') if isinstance(detail, dict) else None
if not isinstance(rows, list):
return DetailSectionSnapshot(
title='字段摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='字段摘要', empty_value='不可用'
),
)
row_dicts = [row for row in rows if isinstance(row, dict)]
primary_key_count = sum(1 for row in row_dicts if self.rendering.render_yes_no(row.get('isPk', '-')) == '是')
required_count = sum(1 for row in row_dicts if self.rendering.render_yes_no(row.get('isRequired', '-')) == '是')
return DetailSectionSnapshot(
title='字段摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 字段规模',
f'字段总数: {payload.get("columnCount", "-")}',
f'主键字段: {primary_key_count}',
f'必填字段: {required_count}',
],
)
def build_gen_columns_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建业务表字段分区。
:param payload: `gen detail` JSON 负载
:return: 分区快照
"""
detail = payload.get('detail') if isinstance(payload, dict) else None
rows = detail.get('rows') if isinstance(detail, dict) else None
if not isinstance(rows, list) or not rows:
return DetailSectionSnapshot(
title='字段列表',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='字段', empty_value='无'),
)
lines: list[str] = []
for row in rows[:12]:
if not isinstance(row, dict):
continue
lines.extend(
[
f'## {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnName", "-"), 24)} · {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnType", "-"), 20)}',
(
f'> 主键: {self.rendering.render_yes_no(row.get("isPk", "-"))} | '
f'必填: {self.rendering.render_yes_no(row.get("isRequired", "-"))} | '
f'查询: {row.get("queryType", "-")}'
),
f'> 说明: {SHELL_TEXT_FORMATTER.truncate_text(row.get("columnComment", "-"), 40)}',
'',
]
)
return DetailSectionSnapshot(
title='字段列表',
status='ok' if payload.get('ok', False) else 'fail',
lines=(lines[:-1] if lines and lines[-1] == '' else lines)
or TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='字段信息',
empty_value='0 列',
detail='当前业务表没有可展示的字段信息',
),
)
def build_gen_precheck_section(
self,
detail_payload: dict[str, Any] | None,
preview_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建代码生成前校验分区。
:param detail_payload: `gen detail` JSON 负载
:param preview_payload: `gen preview` JSON 负载
:return: 分区快照
"""
detail = detail_payload.get('detail') if isinstance(detail_payload, dict) else None
info = detail.get('info') if isinstance(detail, dict) else None
rows = detail.get('rows') if isinstance(detail, dict) and isinstance(detail.get('rows'), list) else []
preview = preview_payload.get('preview') if isinstance(preview_payload, dict) else None
preview_templates = preview if isinstance(preview, dict) else {}
row_dicts = [row for row in rows if isinstance(row, dict)]
has_class_name = bool(isinstance(info, dict) and str(info.get('className', '') or '').strip())
has_module_name = bool(isinstance(info, dict) and str(info.get('moduleName', '') or '').strip())
has_business_name = bool(isinstance(info, dict) and str(info.get('businessName', '') or '').strip())
has_primary_key = any(self.rendering.render_yes_no(row.get('isPk', '-')) == '是' for row in row_dicts)
column_count = len(row_dicts)
template_count = len(preview_templates)
risk_items: list[str] = []
if not has_class_name:
risk_items.append('生成类名缺失')
if not has_module_name:
risk_items.append('所属模块缺失')
if not has_business_name:
risk_items.append('业务标识缺失')
if column_count <= 0:
risk_items.append('字段列表为空')
if not has_primary_key:
risk_items.append('未识别到主键字段')
if template_count <= 0:
risk_items.append('未生成可预览模板')
status = 'ok' if not risk_items else 'warn'
return DetailSectionSnapshot(
title='生成前校验',
status=status,
lines=[
'## 校验结果',
f'生成类名: {"通过" if has_class_name else "缺失"}',
f'所属模块: {"通过" if has_module_name else "缺失"}',
f'业务标识: {"通过" if has_business_name else "缺失"}',
f'字段数量: {column_count}',
f'主键字段: {"通过" if has_primary_key else "缺失"}',
f'模板预览: {template_count} 份',
'',
'## 风险提示',
*(risk_items if risk_items else ['当前未发现阻断生成的明显风险']),
],
)
def build_gen_sync_precheck_section(
self,
table_name: str,
payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建数据库表结构同步前检查分区。
:param table_name: 业务表名称
:param payload: `gen db-list --table-name=...` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='同步预检查',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='同步预检查', empty_value='不可用'
),
)
rows = self.page_adapter.extract_page_rows(payload)
matched_row = next(
(row for row in rows if str(row.get('tableName', '') or '').strip().lower() == table_name.strip().lower()),
None,
)
if matched_row is None:
return DetailSectionSnapshot(
title='同步预检查',
status='warn',
lines=[
'## 当前状态',
f'目标业务表: {table_name}',
'数据库物理表: 未匹配',
'',
'## 建议操作',
'当前数据库中未找到同名物理表,执行同步前应先确认表结构来源',
],
)
return DetailSectionSnapshot(
title='同步预检查',
status='ok',
lines=[
'## 当前状态',
f'目标业务表: {table_name}',
f'数据库物理表: {matched_row.get("tableName", "-")}',
f'表注释: {SHELL_TEXT_FORMATTER.truncate_text(matched_row.get("tableComment", "-"), 56)}',
'',
'## 建议操作',
'已匹配到同名物理表,可继续使用同步数据库表结构动作',
],
)
def build_gen_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建代码预览分区。
:param payload: `gen preview` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='代码预览',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='代码预览', empty_value='不可用'
),
)
preview_payload = payload.get('preview') if isinstance(payload.get('preview'), dict) else {}
lines = [
'## 模板规模',
f'模板数量: {payload.get("templateCount", len(preview_payload))}',
]
if not preview_payload:
lines.extend(
[
'',
*TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='模板样本',
empty_value='0 份',
detail='当前业务表没有可预览的模板输出',
),
]
)
return DetailSectionSnapshot(title='代码预览', status='info', lines=lines)
lines.extend(['', '## 模板样本'])
for template_name, template_content in list(preview_payload.items())[:4]:
preview_lines = str(template_content).splitlines()[:3]
lines.append(f'## {SHELL_TEXT_FORMATTER.truncate_text(template_name, 48)}')
lines.extend(f'> {SHELL_TEXT_FORMATTER.truncate_text(line, 72)}' for line in preview_lines)
lines.append('')
if lines[-1] == '':
lines.pop()
return DetailSectionSnapshot(
title='代码预览',
status='ok',
lines=lines,
)
def build_gen_export_preview_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建代码导出预演分区。
:param payload: `gen export --dry-run` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='导出预览',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='导出预览', empty_value='不可用'
),
)
results = payload.get('results') if isinstance(payload.get('results'), list) else []
lines = [
'## 预演结果',
f'执行模式: {payload.get("mode", "-")}',
f'dry-run: {"是" if payload.get("dryRun", False) else "否"}',
f'表数量: {len(payload.get("tableNames", [])) if isinstance(payload.get("tableNames"), list) else 0}',
f'结果数量: {len(results)}',
'',
'## 摘要',
f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
]
if payload.get('outputFile'):
lines.append(f'输出文件: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("outputFile", "-"), 72)}')
if payload.get('genPath'):
lines.append(f'输出目录: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("genPath", "-"), 72)}')
if results:
lines.extend(['', '## 结果样本'])
for item in results[:4]:
if not isinstance(item, dict):
continue
lines.append(
f'> {item.get("tableName", "-")} · {"成功" if item.get("ok", False) else "失败"} · '
f'{SHELL_TEXT_FORMATTER.truncate_text(item.get("message", "-"), 48)}'
)
return DetailSectionSnapshot(
title='导出预览',
status='ok',
lines=lines,
)
def build_importable_tables_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建数据库可导入表共享分区。
:param payload: `gen db-list` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='可导入数据表',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='数据表', empty_value='不可用'
),
)
rows = self.page_adapter.extract_page_rows(payload)
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='可导入数据表',
empty_value='0 张',
detail='当前环境没有可导入的数据库表',
)
if rows:
lines = [
f'{SHELL_TEXT_FORMATTER.truncate_text(row.get("tableName", "-"), 40)} · {SHELL_TEXT_FORMATTER.truncate_text(row.get("tableComment", "-"), 48)}'
for row in rows[:8]
]
return DetailSectionSnapshot(
title='可导入数据表',
status='ok',
lines=lines,
)
@staticmethod
def build_gen_export_entry_section(table_name: str) -> DetailSectionSnapshot:
"""
构建代码导出向导入口分区。
:param table_name: 当前业务表名称
:return: 分区快照
"""
return DetailSectionSnapshot(
title='导出入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario=f'准备导出当前业务表 {table_name} 的生成结果时,应先通过向导确认目标表、输出目录和覆盖范围。',
command=TUI_COPY.build_cli_command_hint('wizard', 'gen-export', '--output=text'),
guide='进入向导后优先选择当前业务表,并在最终确认前检查导出目录和文件覆盖预览。',
),
)
@staticmethod
def build_gen_import_entry_section(importable_rows: list[dict[str, Any]]) -> DetailSectionSnapshot:
"""
构建物理表导入入口分区。
:param importable_rows: 可导入物理表列表
:return: 分区快照
"""
suggested_table_names = [
str(row.get('tableName', '') or '').strip()
for row in importable_rows[:3]
if str(row.get('tableName', '') or '').strip()
]
suggested_tables = ' '.join(suggested_table_names) if suggested_table_names else '<table_name>'
return DetailSectionSnapshot(
title='导入入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备把数据库物理表纳入代码生成管理时,应先核对物理表名、注释和目标环境,再通过导入命令执行 dry-run 预演。',
command=TUI_COPY.build_cli_command_hint(
'gen', 'import-table', suggested_tables, '--dry-run', '--output=text'
),
guide='建议先从“可导入数据表”中确认目标表名,必要时一次只导入 1 到 3 张表,确认 dry-run 输出后再执行真实导入。',
),
)
@staticmethod
def build_gen_create_entry_section() -> DetailSectionSnapshot:
"""
构建建表 SQL 导入入口分区。
:return: 分区快照
"""
sql_template = 'CREATE TABLE demo_table (id bigint primary key);'
return DetailSectionSnapshot(
title='建表入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备根据建表 SQL 直接创建物理表并导入代码生成配置时,应先使用单条 SQL 执行 dry-run 预演,确认语句可解析且只包含目标表。',
command=TUI_COPY.build_cli_command_hint(
'gen', 'create-table', '--dry-run', '--sql', sql_template, '--output=text'
),
guide='建议优先使用单条 CREATE TABLE 语句做预演;若 SQL 较长或来自文件,可改用 --sql-file 方式,但不要同时传入 --sql 与 --sql-file。',
),
)
def load_gen_detail_sections(
self,
table_row: dict[str, Any],
env: str,
) -> list[DetailSectionSnapshot]:
"""
按需加载单条业务表详情分区。
:param table_row: 业务表列表行数据
:param env: 当前运行环境
:return: 详情分区列表
"""
table_id = table_row.get('tableId')
detail_payload = NESTED_CLI_SUPPORT.run(
'gen',
'detail',
str(table_id),
f'--env={env}',
'--output=json',
parse_json=True,
).payload
preview_payload = NESTED_CLI_SUPPORT.run(
'gen',
'preview',
str(table_id),
f'--env={env}',
'--output=json',
parse_json=True,
).payload
table_name = str(table_row.get('tableName', '-') or '-')
export_payload = NESTED_CLI_SUPPORT.run(
'gen',
'export',
table_name,
f'--env={env}',
'--dry-run',
'--mode=zip',
'--output=json',
'--yes',
parse_json=True,
).payload
sync_check_payload = NESTED_CLI_SUPPORT.run(
'gen',
'db-list',
f'--env={env}',
f'--table-name={table_name}',
'--paged',
'--page-size=5',
'--output=json',
parse_json=True,
).payload
return [
self.build_gen_focus_section(detail_payload),
self.build_gen_generation_section(detail_payload),
self.build_gen_column_summary_section(detail_payload),
self.build_gen_precheck_section(detail_payload, preview_payload),
self.build_gen_sync_precheck_section(table_name, sync_check_payload),
self.build_gen_columns_section(detail_payload),
self.build_gen_preview_section(preview_payload),
self.build_gen_export_preview_section(export_payload),
self.build_gen_export_entry_section(table_name),
]
def build_gen_overview_section(
self,
gen_rows: list[dict[str, Any]],
filtered_rows: list[dict[str, Any]],
importable_rows: list[dict[str, Any]],
) -> DetailSectionSnapshot:
"""
构建代码生成页总览判断共享分区。
:param gen_rows: 全量业务表行列表
:param filtered_rows: 当前筛选后的业务表行列表
:param importable_rows: 可导入物理表行列表
:return: 分区快照
"""
incomplete_count = sum(
1
for row in gen_rows
if not str(row.get('className', '') or '').strip()
or not str(row.get('moduleName', '') or '').strip()
or not str(row.get('businessName', '') or '').strip()
)
status = 'ok'
conclusion = '当前生成配置可继续下钻,优先查看预检查、代码预览和导出预演'
if incomplete_count > 0:
status = 'warn'
conclusion = '存在生成配置不完整的业务表,建议先处理生成前校验风险'
elif not gen_rows and importable_rows:
status = 'info'
conclusion = '当前还没有业务表配置,可先从可导入物理表中选择目标'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'已加载业务表: {len(gen_rows)} 张',
f'当前匹配: {len(filtered_rows)} 张',
f'可导入物理表: {len(importable_rows)} 张',
f'配置待补全: {incomplete_count} 张',
'',
'## 建议入口',
'优先关注:生成前校验 / 同步预检查 / 代码预览 / 导出预览 / 导入入口 / 建表入口',
],
)
class GenRecordBuilder:
"""
代码生成浏览记录构建器。
该对象负责构建代码生成页单条业务表浏览记录与失败兜底记录。
:param page_adapter: 代码生成浏览页适配器
:param section_builder: 代码生成浏览页分区构建器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
section_builder: GenSectionBuilder,
) -> None:
"""
初始化代码生成浏览记录构建器。
:param page_adapter: 代码生成浏览页适配器
:param section_builder: 代码生成浏览页分区构建器
:return: None
"""
self.page_adapter = page_adapter
self.section_builder = section_builder
def build_gen_record(self, table_row: dict[str, Any], env: str) -> BrowserRecordSnapshot:
"""
构建单条业务表浏览记录。
:param table_row: 业务表列表行数据
:param env: 当前运行环境
:return: 浏览记录快照
"""
table_id = table_row.get('tableId', '-')
table_name = str(table_row.get('tableName', '-') or '-')
class_name = str(table_row.get('className', '-') or '-')
module_name = str(table_row.get('moduleName', '-') or '-')
return BrowserRecordSnapshot(
key=f'gen:{table_id}',
title=SHELL_TEXT_FORMATTER.truncate_text(table_name, 40),
status='ok',
summary=f'生成类 {SHELL_TEXT_FORMATTER.truncate_text(class_name, 24)} · 模块 {SHELL_TEXT_FORMATTER.truncate_text(module_name, 20)}',
metadata_lines=[
'## 表身份',
f'表 ID: {table_id}',
'',
'## 生成信息',
f'生成类名: {SHELL_TEXT_FORMATTER.truncate_text(class_name, 32)}',
f'所属模块: {SHELL_TEXT_FORMATTER.truncate_text(module_name, 24)}',
f'业务标识: {SHELL_TEXT_FORMATTER.truncate_text(table_row.get("businessName", "-"), 24)}',
],
detail_sections=[],
detail_loader=lambda table_row=table_row, env=env: self.section_builder.load_gen_detail_sections(
table_row, env
),
)
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
"""
构建代码生成页失败兜底记录。
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return self.page_adapter.build_failure_record(
key='gen:unavailable',
subject='代码生成',
section_subject='业务表列表',
payload=payload,
)
class GenBrowserAdapter(BaseBrowserAdapter):
"""
代码生成浏览页适配器。
该适配器负责采集业务表列表、详情、预览与可导入表清单,并委托
协作对象完成共享分区、详情分区和记录构建。
"""
def __init__(
self,
rendering: GenRenderingSupport | None = None,
section_builder: GenSectionBuilder | None = None,
record_builder: GenRecordBuilder | None = None,
) -> None:
"""
初始化代码生成浏览页适配器。
:param rendering: 代码生成浏览页渲染支持对象
:param section_builder: 代码生成浏览页分区构建器
:param record_builder: 代码生成浏览记录构建器
:return: None
"""
super().__init__(
page_title='代码生成',
search_view_key='gen',
filter_options=(),
)
self.rendering = rendering or GenRenderingSupport()
self.section_builder = section_builder or GenSectionBuilder(self, self.rendering)
self.record_builder = record_builder or GenRecordBuilder(self, self.section_builder)
@staticmethod
def apply_gen_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""
按业务表名、生成类名或模块名查询词过滤业务表行数据。
:param rows: 原始业务表行列表
:param query: 当前搜索词
:return: 过滤后的业务表行列表
"""
normalized_query = str(query).strip().lower()
if not normalized_query:
return rows
return [
row
for row in rows
if normalized_query in str(row.get('tableName', '') or '').strip().lower()
or normalized_query in str(row.get('className', '') or '').strip().lower()
or normalized_query in str(row.get('moduleName', '') or '').strip().lower()
]
def collect_snapshot(self, env: str, query: str = '') -> BrowserPageSnapshot:
"""
采集代码生成浏览页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 浏览页快照
"""
search_context = self.resolve_search_context(query)
gen_tables_payload = NESTED_CLI_SUPPORT.run(
'gen',
'list',
f'--env={env}',
'--paged',
'--page-size=8',
'--output=json',
parse_json=True,
).payload
db_tables_payload = NESTED_CLI_SUPPORT.run(
'gen',
'db-list',
f'--env={env}',
'--paged',
'--page-size=8',
'--output=json',
parse_json=True,
).payload
importable_rows = self.extract_page_rows(db_tables_payload)
if not isinstance(gen_tables_payload, dict) or not gen_tables_payload.get('ok', False):
return BrowserPageSnapshot(
title='代码生成',
subtitle=TUI_COPY.build_unavailable_subtitle(
'业务表',
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(gen_tables_payload), 72),
),
records=[self.record_builder.build_failure_record(gen_tables_payload)],
shared_sections=[
self.section_builder.build_gen_overview_section([], [], importable_rows),
self.section_builder.build_importable_tables_section(db_tables_payload),
self.section_builder.build_gen_import_entry_section(importable_rows),
self.section_builder.build_gen_create_entry_section(),
],
search=search_context,
)
rows = self.extract_page_rows(gen_tables_payload)
filtered_rows = self.apply_gen_query(rows, query)
records = [self.record_builder.build_gen_record(table_row, env) for table_row in filtered_rows[:8]]
if not records:
records = [
self.build_empty_record(
key='gen:none',
subject='业务表',
empty_label='业务表',
has_source_rows=bool(rows),
filtered_summary='当前搜索条件下没有匹配业务表',
empty_summary='当前环境中还没有纳入代码生成的业务表',
filtered_empty_value='暂无匹配',
empty_empty_value='暂无配置',
filtered_detail='当前搜索条件下没有匹配业务表',
empty_detail='当前环境中尚未配置代码生成业务表',
)
]
return BrowserPageSnapshot(
title='代码生成',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_gen_diagnostic_subtitle(
len(filtered_rows),
len(importable_rows),
),
records=records,
shared_sections=[
self.section_builder.build_gen_overview_section(rows, filtered_rows, importable_rows),
self.section_builder.build_importable_tables_section(db_tables_payload),
self.section_builder.build_gen_import_entry_section(importable_rows),
self.section_builder.build_gen_create_entry_section(),
],
search=search_context,
)
GEN_BROWSER_ADAPTER = GenBrowserAdapter()
@@ -0,0 +1,988 @@
from dataclasses import dataclass, field
from typing import Any
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
DEPENDENCY_CHECK_TOTAL = 3
DASHBOARD_PANEL_MAX_LINES = 8
DASHBOARD_PANEL_LINE_MAX_LENGTH = 38
DASHBOARD_PANEL_MORE_HINT = TUI_COPY.build_more_detail_hint()
@dataclass(frozen=True)
class DashboardPanelSnapshot:
"""
TUI 首页单个面板快照。
:param title: 面板标题
:param status: 面板状态
:param lines: 面板正文文本行
"""
title: str
status: str
lines: list[str]
@dataclass(frozen=True)
class DashboardMetricSnapshot:
"""
TUI 首页单个指标卡快照。
:param title: 指标标题
:param value: 指标主值
:param status: 指标状态
:param hint: 指标补充说明
"""
title: str
value: str
status: str
hint: str
@dataclass(frozen=True)
class DashboardSnapshot:
"""
TUI 首页聚合快照。
:param env: 当前运行环境
:param panels: 面板列表
"""
env: str
panels: list[DashboardPanelSnapshot]
metrics: list[DashboardMetricSnapshot] = field(default_factory=list)
@dataclass(frozen=True)
class DashboardSourcePayloads:
"""
TUI 首页聚合数据源快照。
:param app_env_payload: 应用环境结果
:param app_routes_payload: 应用路由结果
:param doctor_payload: 应用检查结果
:param database_payload: 数据库版本结果
:param cache_payload: 缓存统计结果
:param deps_payload: 依赖检查结果
:param server_payload: 服务器摘要结果
"""
app_env_payload: dict[str, Any] | None
app_routes_payload: dict[str, Any] | None
doctor_payload: dict[str, Any] | None
database_payload: dict[str, Any] | None
cache_payload: dict[str, Any] | None
deps_payload: dict[str, Any] | None
server_payload: dict[str, Any] | None
class DashboardSnapshotCollector:
"""
TUI 首页聚合数据采集器。
该对象负责从多路 CLI 命令收集 dashboard 所需原始结果,
让 `DashboardAdapter` 保持首页装配职责,而不继续承载所有采集细节。
"""
def collect(self, env: str) -> DashboardSourcePayloads:
"""
采集首页聚合所需的多路原始结果。
:param env: 当前运行环境
:return: 首页聚合数据源快照
"""
return DashboardSourcePayloads(
app_env_payload=NESTED_CLI_SUPPORT.run(
'app',
'env',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
app_routes_payload=NESTED_CLI_SUPPORT.run(
'app',
'routes',
f'--env={env}',
'--group-by=tag',
'--output=json',
parse_json=True,
).payload,
doctor_payload=NESTED_CLI_SUPPORT.run(
'app',
'doctor',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
database_payload=NESTED_CLI_SUPPORT.run(
'db',
'current',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
cache_payload=NESTED_CLI_SUPPORT.run(
'cache',
'stats',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
deps_payload=NESTED_CLI_SUPPORT.run(
'ops',
'deps',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
server_payload=NESTED_CLI_SUPPORT.run(
'ops',
'server-info',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
)
class DashboardFormattingSupport:
"""
首页仪表盘格式化支持对象。
该对象负责统一处理摘要消息提取、状态优先级、布尔文本、信号条和
面板空态/失败态文案,供面板与指标构建对象共享。
"""
@staticmethod
def truncate_dashboard_line(line: str) -> str:
"""
截断首页面板单行文本,并保留结构化前缀。
:param line: 原始文本行
:return: 截断后的文本行
"""
stripped = str(line).strip()
if not stripped:
return ''
if stripped.startswith('## '):
return f'## {SHELL_TEXT_FORMATTER.truncate_text(stripped[3:].strip(), DASHBOARD_PANEL_LINE_MAX_LENGTH - 3)}'
if stripped.startswith('> '):
return f'> {SHELL_TEXT_FORMATTER.truncate_text(stripped[2:].strip(), DASHBOARD_PANEL_LINE_MAX_LENGTH - 2)}'
return SHELL_TEXT_FORMATTER.truncate_text(stripped, DASHBOARD_PANEL_LINE_MAX_LENGTH)
@staticmethod
def resolve_status_priority(status: str) -> int:
"""
解析状态优先级,值越小越需要优先关注。
:param status: 状态文本
:return: 优先级
"""
return {
'fail': 0,
'warn': 1,
'ok': 2,
'info': 3,
}.get(str(status).strip().lower(), 9)
@staticmethod
def render_bool_flag(value: object) -> str:
"""
将布尔语义值标准化为文本。
:param value: 原始值
:return: 规范化后的文本
"""
return '是' if bool(value) else '否'
@staticmethod
def render_signal_bar(
passed: int,
total: int,
*,
width: int = 8,
on: str = '#',
off: str = '-',
) -> str:
"""
将通过数量渲染为 ASCII 信号条。
:param passed: 已通过数量
:param total: 总数量
:param width: 信号条宽度
:param on: 点亮字符
:param off: 熄灭字符
:return: ASCII 信号条
"""
if total <= 0:
return f'[{off * width}]'
safe_passed = max(0, min(passed, total))
filled = round((safe_passed / total) * width)
return f'[{on * filled}{off * max(0, width - filled)}]'
@staticmethod
def extract_payload_message(payload: dict[str, Any] | None) -> str:
"""
提取结果负载中的摘要消息。
:param payload: 标准结果负载
:return: 摘要文本
"""
if not isinstance(payload, dict):
return '-'
if payload.get('message'):
return str(payload.get('message'))
if payload.get('error'):
return SHELL_TEXT_FORMATTER.truncate_text(payload.get('error'), 120)
return '-'
def build_failure_lines(
self,
payload: dict[str, Any] | None,
*,
summary_label: str,
summary_value: str,
suggestion: str = TUI_COPY.build_dashboard_failure_suggestion(),
) -> list[str]:
"""
构建首页面板统一失败态摘要。
:param payload: 标准结果负载
:param summary_label: 主字段名称
:param summary_value: 主字段值
:param suggestion: 建议操作
:return: 面板正文
"""
return [
'## 当前状态',
f'{summary_label}: {summary_value}',
'',
'## 错误摘要',
f'> {self.extract_payload_message(payload)}',
'',
'## 建议操作',
suggestion,
]
@staticmethod
def build_empty_lines(
*,
summary_label: str,
summary_value: str,
detail: str,
suggestion: str = TUI_COPY.build_dashboard_empty_suggestion(),
) -> list[str]:
"""
构建首页面板统一空态摘要。
:param summary_label: 主字段名称
:param summary_value: 主字段值
:param detail: 空态说明
:param suggestion: 建议操作
:return: 面板正文
"""
return [
'## 当前状态',
f'{summary_label}: {summary_value}',
'',
'## 说明',
detail,
'',
'## 建议操作',
suggestion,
]
class DashboardPanelCompressor:
"""
首页面板压缩器。
该对象负责将原始多行面板压缩为适合首页展示的统一密度摘要卡。
:param formatting: 首页仪表盘格式化支持对象
"""
def __init__(self, formatting: DashboardFormattingSupport) -> None:
"""
初始化首页面板压缩器。
:param formatting: 首页仪表盘格式化支持对象
:return: None
"""
self.formatting = formatting
def compact_panel_lines(self, lines: list[str]) -> list[str]:
"""
将首页面板正文压缩为统一长度的摘要卡内容。
:param lines: 原始正文文本行
:return: 压缩后的摘要行列表
"""
compact_lines: list[str] = []
for raw_line in lines:
compact_line = self.formatting.truncate_dashboard_line(raw_line)
if not compact_line:
if compact_lines and compact_lines[-1] != '':
compact_lines.append('')
continue
compact_lines.append(compact_line)
while compact_lines and compact_lines[0] == '':
compact_lines.pop(0)
while compact_lines and compact_lines[-1] == '':
compact_lines.pop()
if len(compact_lines) <= DASHBOARD_PANEL_MAX_LINES:
return compact_lines
summarized_lines = compact_lines[: DASHBOARD_PANEL_MAX_LINES - 1]
while summarized_lines and summarized_lines[-1] == '':
summarized_lines.pop()
summarized_lines.append(DASHBOARD_PANEL_MORE_HINT)
return summarized_lines
def compact_panels(self, panels: list[DashboardPanelSnapshot]) -> list[DashboardPanelSnapshot]:
"""
将首页所有面板压缩为统一密度的摘要卡。
:param panels: 原始面板列表
:return: 压缩后的面板列表
"""
return [
DashboardPanelSnapshot(
title=panel.title,
status=panel.status,
lines=self.compact_panel_lines(panel.lines),
)
for panel in panels
]
class DashboardPanelBuilder:
"""
首页面板构建器。
该对象负责根据各个 CLI 结果负载构建首页展示面板,并统一风险热区与
建议入口逻辑。
:param formatting: 首页仪表盘格式化支持对象
"""
def __init__(self, formatting: DashboardFormattingSupport) -> None:
"""
初始化首页面板构建器。
:param formatting: 首页仪表盘格式化支持对象
:return: None
"""
self.formatting = formatting
def build_app_env_panel(
self,
env: str,
env_payload: dict[str, Any] | None,
routes_payload: dict[str, Any] | None,
) -> DashboardPanelSnapshot:
"""
构建应用环境总览面板。
:param env: 当前运行环境
:param env_payload: `app env` JSON 负载
:param routes_payload: `app routes` JSON 负载
:return: 面板快照
"""
runtime = env_payload.get('runtime') if isinstance(env_payload, dict) else None
route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0
grouped_routes = routes_payload.get('groupedRoutes') if isinstance(routes_payload, dict) else None
group_count = len(grouped_routes) if isinstance(grouped_routes, dict) else 0
if not isinstance(runtime, dict):
return DashboardPanelSnapshot(
title='应用摘要',
status='fail',
lines=self.formatting.build_failure_lines(
env_payload,
summary_label='环境',
summary_value=env,
suggestion=TUI_COPY.build_dashboard_page_suggestion('应用', '检查环境解析结果'),
),
)
return DashboardPanelSnapshot(
title='应用摘要',
status='ok' if env_payload.get('ok', False) else 'fail',
lines=[
'## 环境映射',
f'当前环境: {env} | CLI 目标环境: {runtime.get("cliEnv", "-")}',
f'配置文件环境: {runtime.get("configEnv", "-")}',
'## 路由摘要',
f'注册路由: {route_count} 条 | 标签分组: {group_count} 个',
'## 环境文件',
f'已加载环境文件: {runtime.get("envFile", "-")}',
f'环境文件存在: {self.formatting.render_bool_flag(runtime.get("envFileExists", False))}',
],
)
def build_health_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
"""
构建健康检查面板。
:param payload: `app doctor` JSON 负载
:return: 面板快照
"""
if not isinstance(payload, dict):
return DashboardPanelSnapshot(
title='系统摘要',
status='fail',
lines=self.formatting.build_failure_lines(
payload,
summary_label='巡检结果',
summary_value='不可用',
suggestion=TUI_COPY.build_dashboard_page_suggestion('运维/健康检查', '查看原因'),
),
)
database = payload.get('database') if isinstance(payload.get('database'), dict) else {}
redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {}
crypto = payload.get('crypto') if isinstance(payload.get('crypto'), dict) else {}
return DashboardPanelSnapshot(
title='系统摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 总体态势',
f'整体结果: {"通过" if payload.get("ok", False) else "异常"}',
'',
'## 依赖检查',
f'数据库: {"正常" if database.get("ok", False) else "异常"}',
f'> {self.formatting.extract_payload_message(database)}',
f'Redis: {"正常" if redis.get("ok", False) else "异常"}',
f'> {self.formatting.extract_payload_message(redis)}',
f'加密组件: {"正常" if crypto.get("ok", False) else "异常"}',
f'> {self.formatting.extract_payload_message(crypto)}',
],
)
def build_recommended_entry_panel(
self,
doctor_payload: dict[str, Any] | None,
database_payload: dict[str, Any] | None,
cache_payload: dict[str, Any] | None,
) -> DashboardPanelSnapshot:
"""
构建首页建议入口面板。
:param doctor_payload: 健康检查负载
:param database_payload: 数据库状态负载
:param cache_payload: 缓存状态负载
:return: 面板快照
"""
database_ok = (
bool(doctor_payload.get('database', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
)
redis_ok = bool(doctor_payload.get('redis', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
crypto_ok = (
bool(doctor_payload.get('crypto', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
)
db_revision = str(database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-')
cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-'
if not database_ok:
status = 'fail'
lines = [
'## 首选入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("database").upper()}] 数据库',
f'> {TUI_DIAGNOSTIC_SERVICE.build_database_focus_hint()}',
'',
'## 次级入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维',
f'> {TUI_DIAGNOSTIC_SERVICE.build_ops_focus_hint()}',
'',
'## 当前判断',
f'迁移版本: {db_revision}',
'> 数据库异常会优先阻断后续业务巡检',
]
elif not redis_ok:
status = 'fail'
lines = [
'## 首选入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("cache").upper()}] 缓存',
f'> {TUI_DIAGNOSTIC_SERVICE.build_cache_focus_hint()}',
'',
'## 次级入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维',
f'> {TUI_DIAGNOSTIC_SERVICE.build_ops_focus_hint()}',
'',
'## 当前判断',
f'Redis 键数: {cache_size}',
'> 缓存异常通常会影响配置读取和任务运行',
]
elif not crypto_ok:
status = 'warn'
lines = [
'## 首选入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("crypto").upper()}] 加密',
'> 先检查运行校验、公钥身份和兼容版本',
'',
'## 次级入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("ops").upper()}] 运维',
'> 对照依赖版本,确认是否缺少加密相关运行依赖',
'',
'## 当前判断',
'> 加密异常不会立即阻断全部能力,但会影响安全链路',
]
else:
status = 'ok'
lines = [
'## 首选入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("jobs").upper()}] 任务 · {TUI_DIAGNOSTIC_SERVICE.build_jobs_focus_hint()}',
'## 次级入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("configs").upper()}] 参数配置 · {TUI_DIAGNOSTIC_SERVICE.build_configs_focus_hint()}',
'## 扩展入口',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("gen").upper()}] 代码生成 · {TUI_DIAGNOSTIC_SERVICE.build_gen_focus_hint()}',
f'[{TUI_KEYMAP_REGISTRY.get_navigation_shortcut("app").upper()}] 应用 · 配置摘要 / 路由状态',
]
return DashboardPanelSnapshot(
title='建议摘要',
status=status,
lines=lines,
)
def build_inspection_conclusion_panel(
self,
doctor_payload: dict[str, Any] | None,
database_payload: dict[str, Any] | None,
cache_payload: dict[str, Any] | None,
) -> DashboardPanelSnapshot:
"""
构建首页巡检结论面板。
:param doctor_payload: 健康检查负载
:param database_payload: 数据库状态负载
:param cache_payload: 缓存状态负载
:return: 面板快照
"""
database_ok = (
bool(doctor_payload.get('database', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
)
redis_ok = bool(doctor_payload.get('redis', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
crypto_ok = (
bool(doctor_payload.get('crypto', {}).get('ok', False)) if isinstance(doctor_payload, dict) else False
)
db_revision = database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-'
cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-'
if not database_ok:
status = 'fail'
conclusion = '数据库存在异常,建议优先处理连接或迁移问题'
next_step = '优先进入数据库页面,确认迁移版本和连接配置'
elif not redis_ok:
status = 'fail'
conclusion = '缓存服务存在异常,建议优先检查 Redis 可用性'
next_step = '优先进入缓存页面,确认连接数、键数量和状态说明'
elif not crypto_ok:
status = 'warn'
conclusion = '加密组件状态异常,建议尽快核对运行依赖'
next_step = '先查看系统摘要面板,再排查加密组件依赖'
else:
status = 'ok'
conclusion = '当前核心依赖状态正常,可继续查看业务分区'
next_step = '优先查看任务与参数配置页面,确认业务侧是否有风险'
return DashboardPanelSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 建议操作',
next_step,
'',
'## 当前快照',
f'迁移版本: {db_revision}',
f'Redis 键数: {cache_size}',
],
)
def build_database_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
"""
构建数据库状态面板。
:param payload: `db current` JSON 负载
:return: 面板快照
"""
current_revision = payload.get('currentRevision', '-') if isinstance(payload, dict) else '-'
if not isinstance(payload, dict) or not payload.get('ok', False):
return DashboardPanelSnapshot(
title='数据库摘要',
status='fail',
lines=self.formatting.build_failure_lines(
payload,
summary_label='迁移版本',
summary_value=str(current_revision or '-'),
suggestion=TUI_COPY.build_dashboard_page_suggestion('数据库', '确认迁移版本与连接状态'),
),
)
return DashboardPanelSnapshot(
title='数据库摘要',
status='ok',
lines=[
'## 迁移信息',
f'迁移版本: {current_revision}',
'',
'## 状态说明',
f'> {self.formatting.extract_payload_message(payload)}',
],
)
def build_cache_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
"""
构建缓存状态面板。
:param payload: `cache stats` JSON 负载
:return: 面板快照
"""
if not isinstance(payload, dict):
return DashboardPanelSnapshot(
title='缓存摘要',
status='fail',
lines=self.formatting.build_failure_lines(
payload,
summary_label='缓存状态',
summary_value='不可用',
suggestion=TUI_COPY.build_dashboard_page_suggestion('缓存', '查看 Redis 状态'),
),
)
info = payload.get('info') if isinstance(payload.get('info'), dict) else {}
cache_names = payload.get('cacheNames') if isinstance(payload.get('cacheNames'), list) else []
return DashboardPanelSnapshot(
title='缓存摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## Redis 概况',
f'当前键数: {payload.get("dbSize", "-")}',
f'Redis 版本: {info.get("redis_version", "-")}',
f'客户端连接数: {info.get("connected_clients", "-")}',
'',
'## 缓存资源',
f'已登记缓存名: {len(cache_names)} 个',
],
)
def build_dependency_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
"""
构建首页依赖版本摘要面板。
:param payload: `ops deps` JSON 负载
:return: 面板快照
"""
if not isinstance(payload, dict):
return DashboardPanelSnapshot(
title='依赖版本',
status='fail',
lines=self.formatting.build_failure_lines(
payload,
summary_label='依赖检查',
summary_value='不可用',
suggestion=TUI_COPY.build_dashboard_page_suggestion('运维', '检查依赖详情'),
),
)
packages = payload.get('packages') if isinstance(payload.get('packages'), dict) else {}
missing_required = payload.get('missingRequired') if isinstance(payload.get('missingRequired'), list) else []
lines = [
'## 依赖结论',
f'检查结果: {"通过" if payload.get("ok", False) else "异常"}',
f'缺失核心依赖: {len(missing_required)} 个',
'',
'## 关键版本',
]
for package_name in ('python', 'fastapi', 'sqlalchemy', 'redis'):
package_payload = packages.get(package_name)
if not isinstance(package_payload, dict):
continue
installed = '已安装' if package_payload.get('installed', False) else '缺失'
lines.append(f'{package_name}: {installed} · {package_payload.get("version", "-") or "-"}')
return DashboardPanelSnapshot(
title='依赖版本',
status='ok' if payload.get('ok', False) else 'fail',
lines=lines,
)
def build_server_info_panel(self, payload: dict[str, Any] | None) -> DashboardPanelSnapshot:
"""
构建首页服务器概况面板。
:param payload: `ops server-info` JSON 负载
:return: 面板快照
"""
server = payload.get('server') if isinstance(payload, dict) else None
if not isinstance(server, dict):
return DashboardPanelSnapshot(
title='服务器摘要',
status='fail',
lines=self.formatting.build_failure_lines(
payload,
summary_label='服务器摘要',
summary_value='不可用',
suggestion=TUI_COPY.build_dashboard_page_suggestion('运维', '重新采集服务器摘要'),
),
)
sys_info = server.get('sys') if isinstance(server.get('sys'), dict) else {}
cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {}
mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {}
return DashboardPanelSnapshot(
title='服务器摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 主机状态',
f'主机名: {sys_info.get("computerName", "-")}',
f'主机 IP: {sys_info.get("computerIp", "-")}',
'',
'## 资源概况',
f'CPU 使用率: {cpu_info.get("used", "-")}%',
f'内存使用率: {mem_info.get("usage", "-")}%',
],
)
def build_risk_heatmap_panel(self, panels: list[DashboardPanelSnapshot]) -> DashboardPanelSnapshot:
"""
构建首页风险热区面板。
:param panels: 当前已构建的面板列表
:return: 风险热区面板
"""
sorted_panels = sorted(
panels, key=lambda item: (self.formatting.resolve_status_priority(item.status), item.title)
)
fail_panels = [panel for panel in sorted_panels if panel.status == 'fail']
warn_panels = [panel for panel in sorted_panels if panel.status == 'warn']
primary_targets = fail_panels[:3] or warn_panels[:3] or sorted_panels[:3]
lines = [
'## 热区摘要',
f'失败面板: {len(fail_panels)} 个',
f'警告面板: {len(warn_panels)} 个',
'',
'## 优先关注',
]
if not primary_targets:
lines.extend(
self.formatting.build_empty_lines(
summary_label='风险热区',
summary_value='0 个',
detail='当前没有需要优先处理的失败或警告面板',
suggestion='可继续浏览任务、缓存、参数配置等业务页面',
)
)
else:
for index, panel in enumerate(primary_targets, start=1):
lead_line = SHELL_TEXT_FORMATTER.truncate_text(
panel.lines[1] if len(panel.lines) > 1 else panel.lines[0], 56
)
lines.extend(
[
f'> HOT-{index:02d} [{panel.status.upper()}] {panel.title}',
f'> {lead_line}',
]
)
status = 'fail' if fail_panels else 'warn' if warn_panels else 'ok'
return DashboardPanelSnapshot(
title='风险摘要',
status=status,
lines=lines,
)
def build_panels(
self,
*,
env: str,
app_env_payload: dict[str, Any] | None,
app_routes_payload: dict[str, Any] | None,
doctor_payload: dict[str, Any] | None,
database_payload: dict[str, Any] | None,
cache_payload: dict[str, Any] | None,
deps_payload: dict[str, Any] | None,
server_payload: dict[str, Any] | None,
) -> list[DashboardPanelSnapshot]:
"""
构建首页全部原始面板。
:param env: 当前运行环境
:param app_env_payload: 应用环境负载
:param app_routes_payload: 应用路由负载
:param doctor_payload: 健康检查负载
:param database_payload: 数据库状态负载
:param cache_payload: 缓存状态负载
:param deps_payload: 运维依赖负载
:param server_payload: 服务器摘要负载
:return: 原始面板列表
"""
panels = [
self.build_app_env_panel(env, app_env_payload, app_routes_payload),
self.build_health_panel(doctor_payload),
self.build_inspection_conclusion_panel(doctor_payload, database_payload, cache_payload),
self.build_recommended_entry_panel(doctor_payload, database_payload, cache_payload),
self.build_database_panel(database_payload),
self.build_cache_panel(cache_payload),
self.build_dependency_panel(deps_payload),
self.build_server_info_panel(server_payload),
]
panels.append(self.build_risk_heatmap_panel(panels))
return panels
class DashboardMetricBuilder:
"""
首页指标卡构建器。
该对象负责根据健康检查、数据库和缓存状态构建首页驾驶舱指标卡。
:param formatting: 首页仪表盘格式化支持对象
"""
def __init__(self, formatting: DashboardFormattingSupport) -> None:
"""
初始化首页指标卡构建器。
:param formatting: 首页仪表盘格式化支持对象
:return: None
"""
self.formatting = formatting
def build_metrics(
self,
doctor_payload: dict[str, Any] | None,
database_payload: dict[str, Any] | None,
cache_payload: dict[str, Any] | None,
) -> list[DashboardMetricSnapshot]:
"""
构建首页驾驶舱指标卡。
:param doctor_payload: 健康检查负载
:param database_payload: 数据库状态负载
:param cache_payload: 缓存状态负载
:return: 指标卡列表
"""
database = doctor_payload.get('database') if isinstance(doctor_payload, dict) else None
redis = doctor_payload.get('redis') if isinstance(doctor_payload, dict) else None
crypto = doctor_payload.get('crypto') if isinstance(doctor_payload, dict) else None
dependency_results = [
bool(database.get('ok', False)) if isinstance(database, dict) else False,
bool(redis.get('ok', False)) if isinstance(redis, dict) else False,
bool(crypto.get('ok', False)) if isinstance(crypto, dict) else False,
]
passed_dependencies = sum(1 for item in dependency_results if item)
posture = '风险' if isinstance(doctor_payload, dict) and not doctor_payload.get('ok', False) else '稳定'
posture_status = 'fail' if posture == '风险' else 'ok'
current_revision = database_payload.get('currentRevision', '-') if isinstance(database_payload, dict) else '-'
cache_size = cache_payload.get('dbSize', '-') if isinstance(cache_payload, dict) else '-'
metrics = [
DashboardMetricSnapshot(
title='当前态势',
value=f'{self.formatting.render_signal_bar(passed_dependencies, DEPENDENCY_CHECK_TOTAL)} {posture}',
status=posture_status,
hint=f'依赖通过 {passed_dependencies}/{DEPENDENCY_CHECK_TOTAL},健康检查自动判定',
),
DashboardMetricSnapshot(
title='依赖通过率',
value=(
f'{self.formatting.render_signal_bar(passed_dependencies, DEPENDENCY_CHECK_TOTAL)} '
f'{passed_dependencies}/{DEPENDENCY_CHECK_TOTAL}'
),
status=(
'ok'
if passed_dependencies == DEPENDENCY_CHECK_TOTAL
else 'warn'
if passed_dependencies > 0
else 'fail'
),
hint='数据库、Redis、加密组件',
),
DashboardMetricSnapshot(
title='迁移版本',
value=f'[{SHELL_TEXT_FORMATTER.truncate_text(current_revision, 16)}]',
status='ok' if current_revision != '-' else 'warn',
hint='当前数据库 revision 基线',
),
DashboardMetricSnapshot(
title='Redis 键数',
value=f'[KEYS] {cache_size}',
status='info',
hint='当前缓存库 key 数量观测值',
),
]
return sorted(
metrics,
key=lambda item: self.formatting.resolve_status_priority(item.status),
)
class DashboardAdapter:
"""
TUI 首页巡检聚合适配器。
该适配器负责采集应用、健康检查、数据库、缓存与运维只读快照,
并委托面板构建器、指标构建器和压缩器组装首页结果。
"""
def __init__(
self,
formatting: DashboardFormattingSupport | None = None,
panel_builder: DashboardPanelBuilder | None = None,
metric_builder: DashboardMetricBuilder | None = None,
panel_compressor: DashboardPanelCompressor | None = None,
snapshot_collector: DashboardSnapshotCollector | None = None,
) -> None:
"""
初始化首页巡检聚合适配器。
:param formatting: 首页仪表盘格式化支持对象
:param panel_builder: 首页面板构建器
:param metric_builder: 首页指标卡构建器
:param panel_compressor: 首页面板压缩器
:param snapshot_collector: 首页聚合数据采集器
:return: None
"""
self.formatting = formatting or DashboardFormattingSupport()
self.panel_builder = panel_builder or DashboardPanelBuilder(self.formatting)
self.metric_builder = metric_builder or DashboardMetricBuilder(self.formatting)
self.panel_compressor = panel_compressor or DashboardPanelCompressor(self.formatting)
self.snapshot_collector = snapshot_collector or DashboardSnapshotCollector()
def collect_snapshot(self, env: str) -> DashboardSnapshot:
"""
采集 TUI 首页只读巡检快照。
:param env: 当前运行环境
:return: 首页聚合快照
"""
source_payloads = self.snapshot_collector.collect(env)
panels = self.panel_builder.build_panels(
env=env,
app_env_payload=source_payloads.app_env_payload,
app_routes_payload=source_payloads.app_routes_payload,
doctor_payload=source_payloads.doctor_payload,
database_payload=source_payloads.database_payload,
cache_payload=source_payloads.cache_payload,
deps_payload=source_payloads.deps_payload,
server_payload=source_payloads.server_payload,
)
return DashboardSnapshot(
env=env,
metrics=self.metric_builder.build_metrics(
source_payloads.doctor_payload,
source_payloads.database_payload,
source_payloads.cache_payload,
),
panels=self.panel_compressor.compact_panels(panels),
)
DASHBOARD_ADAPTER = DashboardAdapter()
@@ -0,0 +1,747 @@
from typing import Any
from cli.tui.adapters.base import BaseBrowserAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
BrowserPageSnapshot,
BrowserRecordSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.tui.search import JOB_FILTER_OPTIONS
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
class JobRenderingSupport:
"""
任务浏览页渲染支持对象。
该对象负责状态文本、时间线标记、成功率信号条和失败任务提取等基础
逻辑,供筛选、分区和记录构建复用。
:param page_adapter: 任务浏览页适配器
"""
def __init__(self, page_adapter: BaseBrowserAdapter) -> None:
"""
初始化任务浏览页渲染支持对象。
:param page_adapter: 任务浏览页适配器
:return: None
"""
self.page_adapter = page_adapter
def extract_failed_job_names(self, payload: dict[str, Any] | None) -> set[str]:
"""
从失败日志结果中提取任务名称集合。
:param payload: 失败日志负载
:return: 失败任务名称集合
"""
rows = self.page_adapter.extract_page_rows(payload)
failed_job_names: set[str] = set()
for row in rows:
job_name = str(row.get('jobName', '') or '').strip()
if job_name:
failed_job_names.add(job_name)
return failed_job_names
@staticmethod
def render_job_status(value: object) -> str:
"""
将任务状态码转换为可读文本。
:param value: 原始状态值
:return: 中文状态文本
"""
normalized = str(value).strip()
if normalized == '0':
return '正常'
if normalized == '1':
return '暂停'
return normalized or '-'
@staticmethod
def render_job_log_status(value: object) -> str:
"""
将任务日志状态码转换为可读文本。
:param value: 原始状态值
:return: 中文状态文本
"""
normalized = str(value).strip()
if normalized == '0':
return '成功'
if normalized == '1':
return '失败'
return normalized or '-'
def render_job_log_timeline_title(self, row: dict[str, Any]) -> str:
"""
渲染单条任务日志时间线标题。
:param row: 日志行数据
:return: 时间线标题文本
"""
created_at = SHELL_TEXT_FORMATTER.truncate_text(row.get('createTime', '-'), 24)
if created_at and created_at != '-':
return f'{created_at} · {self.render_job_log_status(row.get("status", "-"))}'
return f'日志 {row.get("jobLogId", "-")} · {self.render_job_log_status(row.get("status", "-"))}'
@staticmethod
def render_timeline_marker(status: object) -> str:
"""
根据日志状态渲染时间线节点标记。
:param status: 原始日志状态
:return: 节点标记
"""
return 'x' if str(status).strip() == '1' else 'o'
@staticmethod
def render_signal_bar(passed: int, total: int, *, width: int = 8) -> str:
"""
渲染 ASCII 成功率条。
:param passed: 成功数量
:param total: 总数量
:param width: 条形宽度
:return: ASCII 条形
"""
if total <= 0:
return f'[{"-" * width}]'
safe_passed = max(0, min(passed, total))
filled = round((safe_passed / total) * width)
return f'[{"#" * filled}{"-" * max(0, width - filled)}]'
@staticmethod
def extract_latest_log_time(rows: list[dict[str, Any]]) -> str:
"""
提取日志列表中的最近时间。
:param rows: 日志行数据
:return: 最近时间文本
"""
if not rows:
return '-'
latest_time = str(rows[0].get('createTime', '-') or '-').strip()
return latest_time or '-'
class JobRowFilter:
"""
任务浏览行过滤器。
该对象负责按筛选键和搜索词过滤任务行数据。
"""
@staticmethod
def apply_job_filter(
rows: list[dict[str, Any]],
failed_job_names: set[str],
filter_key: str,
) -> list[dict[str, Any]]:
"""
按筛选键过滤任务行数据。
:param rows: 原始任务行列表
:param failed_job_names: 最近失败任务名称集合
:param filter_key: 当前筛选键
:return: 过滤后的任务行列表
"""
normalized_filter = str(filter_key).strip().lower()
if normalized_filter == 'failed':
return [row for row in rows if str(row.get('jobName', '') or '').strip() in failed_job_names]
if normalized_filter == 'paused':
return [row for row in rows if str(row.get('status', '')).strip() == '1']
if normalized_filter == 'ok':
return [
row
for row in rows
if str(row.get('jobName', '') or '').strip() not in failed_job_names
and str(row.get('status', '')).strip() != '1'
]
return rows
@staticmethod
def apply_job_query(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
"""
按任务名称查询词过滤任务行数据。
:param rows: 原始任务行列表
:param query: 当前搜索词
:return: 过滤后的任务行列表
"""
normalized_query = str(query).strip().lower()
if not normalized_query:
return rows
return [
row
for row in rows
if normalized_query in str(row.get('jobName', '') or '').strip().lower()
or normalized_query in str(row.get('jobGroup', '') or '').strip().lower()
]
class JobSectionBuilder:
"""
任务浏览页分区构建器。
该构建器负责构建任务页共享分区以及单条任务详情分区。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
rendering: JobRenderingSupport,
) -> None:
"""
初始化任务浏览页分区构建器。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
:return: None
"""
self.page_adapter = page_adapter
self.rendering = rendering
def build_job_failure_aggregate_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建任务失败聚合共享分区。
:param payload: 失败日志 JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title='失败聚合',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='失败聚合', empty_value='不可用'
),
)
rows = self.page_adapter.extract_page_rows(payload)
if not rows:
return DetailSectionSnapshot(
title='失败聚合',
status='ok',
lines=TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='失败日志',
empty_value='0 条',
detail='当前失败日志池为空,最近没有采集到失败执行记录',
),
)
grouped_rows: dict[str, list[dict[str, Any]]] = {}
for row in rows:
job_name = str(row.get('jobName', '-') or '-').strip() or '-'
grouped_rows.setdefault(job_name, []).append(row)
sorted_groups = sorted(
grouped_rows.items(),
key=lambda item: (-len(item[1]), self.rendering.extract_latest_log_time(item[1]), item[0]),
reverse=False,
)
lines = [
'## 失败池',
f'失败日志: {len(rows)} 条',
f'涉及任务: {len(grouped_rows)} 个',
f'最近失败: {self.rendering.extract_latest_log_time(rows)}',
'',
'## 高频失败任务',
]
for index, (job_name, job_rows) in enumerate(sorted_groups[:6], start=1):
latest_row = job_rows[0]
latest_time = self.rendering.extract_latest_log_time(job_rows)
latest_message = SHELL_TEXT_FORMATTER.truncate_text(latest_row.get('jobMessage', '-'), 48)
lines.extend(
[
f'[{index}] {SHELL_TEXT_FORMATTER.truncate_text(job_name, 28)} · {len(job_rows)} 次',
f'> 最近失败: {latest_time}',
f'> 最近结果: {latest_message}',
]
)
exception_info = SHELL_TEXT_FORMATTER.truncate_text(latest_row.get('exceptionInfo', ''), 48)
if exception_info:
lines.append(f'> 异常摘要: {exception_info}')
lines.append('')
if lines[-1] == '':
lines.pop()
return DetailSectionSnapshot(
title='失败聚合',
status='warn',
lines=lines,
)
def build_jobs_overview_section(
self,
rows: list[dict[str, Any]],
filtered_rows: list[dict[str, Any]],
failed_job_names: set[str],
paused_count: int,
failed_logs_payload: dict[str, Any] | None,
filter_label: str,
) -> DetailSectionSnapshot:
"""
构建任务页总览判断共享分区。
:param rows: 原始任务行列表
:param filtered_rows: 当前筛选后的任务行列表
:param failed_job_names: 最近失败任务名称集合
:param paused_count: 暂停任务数量
:param failed_logs_payload: 失败日志负载
:param filter_label: 当前筛选标签
:return: 分区快照
"""
failed_rows = self.page_adapter.extract_page_rows(failed_logs_payload)
latest_failure = self.rendering.extract_latest_log_time(failed_rows)
status = 'ok'
conclusion = '当前任务基线正常,可继续查看执行轨迹与最近执行记录'
if failed_job_names:
status = 'warn'
conclusion = '存在失败任务,优先查看失败聚合与失败执行记录'
elif paused_count > 0:
status = 'warn'
conclusion = '存在暂停任务,建议确认停用原因与恢复窗口'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'当前筛选: {filter_label}',
f'已加载任务: {len(rows)} 条',
f'当前匹配: {len(filtered_rows)} 条',
f'失败任务: {len(failed_job_names)} 个',
f'暂停任务: {paused_count} 个',
f'最近失败: {latest_failure}',
'',
'## 建议入口',
'优先关注:失败聚合 / 暂停任务 / 执行轨迹',
],
)
def build_job_focus_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建任务概览分区。
:param payload: `job detail` JSON 负载
:return: 分区快照
"""
job = payload.get('job') if isinstance(payload, dict) else None
if not isinstance(job, dict):
return DetailSectionSnapshot(
title='任务摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='任务', empty_value='无'),
)
return DetailSectionSnapshot(
title='任务摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 任务身份',
f'任务 ID: {job.get("jobId", "-")}',
f'任务名称: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobName", "-"), 40)}',
f'任务分组: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobGroup", "-"), 24)}',
f'运行状态: {self.rendering.render_job_status(job.get("status", "-"))}',
],
)
@staticmethod
def build_job_schedule_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建任务调度配置分区。
:param payload: `job detail` JSON 负载
:return: 分区快照
"""
job = payload.get('job') if isinstance(payload, dict) else None
if not isinstance(job, dict):
return DetailSectionSnapshot(
title='调度配置',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='调度配置', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='调度配置',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 调度表达式',
f'Cron 表达式: {SHELL_TEXT_FORMATTER.truncate_text(job.get("cronExpression", "-"), 48)}',
'',
'## 执行入口',
f'调用目标: {SHELL_TEXT_FORMATTER.truncate_text(job.get("invokeTarget", "-"), 72)}',
],
)
def build_job_logs_section(self, payload: dict[str, Any] | None, *, title: str) -> DetailSectionSnapshot:
"""
构建任务日志分区。
:param payload: `job logs` JSON 负载
:param title: 分区标题
:return: 分区快照
"""
if not isinstance(payload, dict) or not payload.get('ok', False):
return DetailSectionSnapshot(
title=title,
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='日志', empty_value='不可用'),
)
rows = self.page_adapter.extract_page_rows(payload)
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='日志记录',
empty_value='0 条',
detail='当前筛选条件下没有相关日志',
)
section_status = 'ok'
if rows:
lines = []
if any(str(row.get('status', '')).strip() == '1' for row in rows):
section_status = 'warn'
for row in rows[:8]:
exception_info = SHELL_TEXT_FORMATTER.truncate_text(row.get('exceptionInfo', '-'), 56)
timeline_marker = self.rendering.render_timeline_marker(row.get('status', '-'))
rendered_status = self.rendering.render_job_log_status(row.get('status', '-'))
lines.extend(
[
f'## 节点 {row.get("jobLogId", "-")} · {self.rendering.render_job_log_timeline_title(row)}',
f'> {timeline_marker} 状态: {rendered_status}',
f'> 时间: {SHELL_TEXT_FORMATTER.truncate_text(row.get("createTime", "-"), 24)}',
f'> 任务: {SHELL_TEXT_FORMATTER.truncate_text(row.get("jobName", "-"), 24)}',
f'> 结果: {SHELL_TEXT_FORMATTER.truncate_text(row.get("jobMessage", "-"), 56)}',
*([f'> 异常: {exception_info}'] if exception_info and exception_info != '-' else []),
'> 轨道: ├─采集 ├─执行 └─落盘',
f'> 轨迹: {"●" if timeline_marker == "o" else "▲"}─{"●" if timeline_marker == "o" else "▲"}─◎',
'',
]
)
return DetailSectionSnapshot(
title=title,
status=section_status,
lines=lines[:-1] if len(lines) > 1 and lines[-1] == '' else lines,
)
def build_job_log_summary_section(
self,
recent_logs_payload: dict[str, Any] | None,
failed_logs_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建任务日志摘要分区。
:param recent_logs_payload: 最近日志 JSON 负载
:param failed_logs_payload: 失败日志 JSON 负载
:return: 分区快照
"""
if not isinstance(recent_logs_payload, dict) or not recent_logs_payload.get('ok', False):
return DetailSectionSnapshot(
title='执行摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
recent_logs_payload, empty_label='日志摘要', empty_value='不可用'
),
)
recent_rows = self.page_adapter.extract_page_rows(recent_logs_payload)
failed_rows = self.page_adapter.extract_page_rows(failed_logs_payload)
success_count = sum(1 for row in recent_rows if str(row.get('status', '')).strip() == '0')
failed_count = sum(1 for row in recent_rows if str(row.get('status', '')).strip() not in {'', '0'})
status = 'warn' if failed_count > 0 or failed_rows else 'ok'
total_count = len(recent_rows)
latest_run = self.rendering.extract_latest_log_time(recent_rows)
latest_failure = self.rendering.extract_latest_log_time(failed_rows)
return DetailSectionSnapshot(
title='执行摘要',
status=status,
lines=[
'## 执行统计',
f'最近采样日志: {total_count} 条',
f'执行成功: {success_count} 条',
f'执行失败: {failed_count} 条',
f'成功率信号: {self.rendering.render_signal_bar(success_count, total_count)} {success_count}/{total_count or 0}',
'',
'## 时间线锚点',
f'最近一次执行: {latest_run}',
f'最近一次失败: {latest_failure}',
'',
'## 轨道视图',
f'执行轨道: {"─".join("●" if str(row.get("status", "")).strip() == "0" else "▲" for row in recent_rows[:6]) or "-"}',
f'失败轨道: {"─".join("▲" for _ in failed_rows[:6]) or "-"}',
f'轨道窗口: {"[执行流]" if recent_rows else "-"} {"[失败流]" if failed_rows else ""}'.strip(),
'',
'## 风险信号',
f'失败记录池: {len(failed_rows)} 条',
],
)
def load_job_detail_sections(self, job_row: dict[str, Any], env: str) -> list[DetailSectionSnapshot]:
"""
按需加载单条任务详情与日志分区。
:param job_row: 任务列表行数据
:param env: 当前运行环境
:return: 详情分区列表
"""
job_id = job_row.get('jobId', '-')
job_name = str(job_row.get('jobName', '-') or '-')
detail_payload = NESTED_CLI_SUPPORT.run(
'job',
'detail',
str(job_id),
f'--env={env}',
'--output=json',
parse_json=True,
).payload
log_arguments = ['job', 'logs', f'--env={env}', '--paged', '--page-size=8', '--output=json']
if job_name.strip() and job_name != '-':
log_arguments.append(f'--job-name={job_name}')
recent_logs_payload = NESTED_CLI_SUPPORT.run(*log_arguments, parse_json=True).payload
failed_logs_payload = NESTED_CLI_SUPPORT.run(*[*log_arguments, '--status=1'], parse_json=True).payload
return [
self.build_job_focus_section(detail_payload),
self.build_job_schedule_section(detail_payload),
self.build_job_log_summary_section(recent_logs_payload, failed_logs_payload),
self.build_job_logs_section(recent_logs_payload, title='最近执行记录'),
self.build_job_logs_section(failed_logs_payload, title='失败执行记录'),
]
class JobRecordBuilder:
"""
任务浏览记录构建器。
该对象负责构建任务页单条浏览记录与失败兜底记录。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
:param section_builder: 任务浏览页分区构建器
"""
def __init__(
self,
page_adapter: BaseBrowserAdapter,
rendering: JobRenderingSupport,
section_builder: JobSectionBuilder,
) -> None:
"""
初始化任务浏览记录构建器。
:param page_adapter: 任务浏览页适配器
:param rendering: 任务浏览页渲染支持对象
:param section_builder: 任务浏览页分区构建器
:return: None
"""
self.page_adapter = page_adapter
self.rendering = rendering
self.section_builder = section_builder
def build_job_record(self, job_row: dict[str, Any], env: str) -> BrowserRecordSnapshot:
"""
构建单条任务浏览记录。
:param job_row: 任务列表行数据
:param env: 当前运行环境
:return: 浏览记录快照
"""
job_id = job_row.get('jobId', '-')
job_name = str(job_row.get('jobName', '-') or '-')
job_status = str(job_row.get('status', '-') or '-')
job_group = str(job_row.get('jobGroup', '-') or '-')
cron_expression = str(job_row.get('cronExpression', '-') or '-')
rendered_status = self.rendering.render_job_status(job_status)
return BrowserRecordSnapshot(
key=f'job:{job_id}',
title=SHELL_TEXT_FORMATTER.truncate_text(job_name, 40),
status='warn' if str(job_status).strip() == '1' else 'ok',
summary=f'{rendered_status} · Cron {SHELL_TEXT_FORMATTER.truncate_text(cron_expression, 32)}',
metadata_lines=[
'## 任务标识',
f'任务 ID: {job_id}',
f'任务分组: {SHELL_TEXT_FORMATTER.truncate_text(job_group, 24)}',
f'运行状态: {rendered_status}',
'',
'## 调度信息',
f'Cron 表达式: {SHELL_TEXT_FORMATTER.truncate_text(cron_expression, 48)}',
],
detail_sections=[],
detail_loader=lambda job_row=job_row, env=env: self.section_builder.load_job_detail_sections(job_row, env),
)
def build_failure_record(self, payload: dict[str, Any] | None) -> BrowserRecordSnapshot:
"""
构建任务页失败兜底记录。
:param payload: 失败结果负载
:return: 浏览记录快照
"""
return self.page_adapter.build_failure_record(
key='job:unavailable',
subject='任务',
section_subject='任务列表',
payload=payload,
)
class JobsBrowserAdapter(BaseBrowserAdapter):
"""
任务浏览页适配器。
该适配器负责采集任务列表、失败日志和单条任务详情,并委托协作对象
完成过滤、共享分区、详情分区和记录构建。
"""
def __init__(
self,
rendering: JobRenderingSupport | None = None,
row_filter: JobRowFilter | None = None,
section_builder: JobSectionBuilder | None = None,
record_builder: JobRecordBuilder | None = None,
) -> None:
"""
初始化任务浏览页适配器。
:param rendering: 任务浏览页渲染支持对象
:param row_filter: 任务浏览行过滤器
:param section_builder: 任务浏览页分区构建器
:param record_builder: 任务浏览记录构建器
:return: None
"""
super().__init__(
page_title='任务',
search_view_key='jobs',
filter_options=JOB_FILTER_OPTIONS,
)
self.rendering = rendering or JobRenderingSupport(self)
self.row_filter = row_filter or JobRowFilter()
self.section_builder = section_builder or JobSectionBuilder(self, self.rendering)
self.record_builder = record_builder or JobRecordBuilder(self, self.rendering, self.section_builder)
def collect_snapshot(self, env: str, filter_key: str = 'all', query: str = '') -> BrowserPageSnapshot:
"""
采集定时任务浏览页只读快照。
:param env: 当前运行环境
:param filter_key: 当前筛选键
:param query: 当前搜索词
:return: 浏览页快照
"""
active_filter_option = self.resolve_active_filter(filter_key)
active_filter = active_filter_option.key
active_filter_label = active_filter_option.label
search_context = self.resolve_search_context(query)
jobs_payload = NESTED_CLI_SUPPORT.run(
'job',
'list',
f'--env={env}',
'--paged',
'--page-size=8',
'--output=json',
parse_json=True,
).payload
failed_logs_payload = NESTED_CLI_SUPPORT.run(
'job',
'logs',
f'--env={env}',
'--paged',
'--page-size=20',
'--status=1',
'--output=json',
parse_json=True,
).payload
failed_job_names = self.rendering.extract_failed_job_names(failed_logs_payload)
if not isinstance(jobs_payload, dict) or not jobs_payload.get('ok', False):
return BrowserPageSnapshot(
title='任务',
subtitle=TUI_COPY.build_unavailable_subtitle(
'任务',
SHELL_TEXT_FORMATTER.truncate_text(self.extract_payload_message(jobs_payload), 72),
),
records=[self.record_builder.build_failure_record(jobs_payload)],
shared_sections=[
self.section_builder.build_jobs_overview_section(
[], [], failed_job_names, 0, failed_logs_payload, active_filter_label
),
self.section_builder.build_job_failure_aggregate_section(failed_logs_payload),
],
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
rows = self.extract_page_rows(jobs_payload)
sorted_rows = sorted(
rows,
key=lambda row: (
0
if str(row.get('jobName', '') or '').strip() in failed_job_names
else 1
if str(row.get('status', '')).strip() == '1'
else 2,
str(row.get('jobName', '') or ''),
),
)
filtered_rows = self.row_filter.apply_job_query(
self.row_filter.apply_job_filter(sorted_rows, failed_job_names, active_filter),
query,
)
records = [self.record_builder.build_job_record(job_row, env) for job_row in filtered_rows[:8]]
if not records:
records = [
self.build_empty_record(
key='job:none',
subject='任务',
empty_label='任务列表',
has_source_rows=bool(sorted_rows),
filtered_summary='当前筛选条件下没有匹配任务',
empty_summary='当前环境中还没有定时任务',
filtered_empty_value='暂无任务',
empty_empty_value='暂无任务配置',
filtered_detail='当前筛选条件下没有匹配任务',
empty_detail='当前环境中尚未配置定时任务',
)
]
paused_count = sum(1 for row in rows if str(row.get('status', '')).strip() == '1')
shared_sections = [
self.section_builder.build_jobs_overview_section(
rows,
filtered_rows,
failed_job_names,
paused_count,
failed_logs_payload,
active_filter_label,
),
self.section_builder.build_job_failure_aggregate_section(failed_logs_payload),
]
return BrowserPageSnapshot(
title='任务',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_jobs_diagnostic_subtitle(
active_filter_label,
len(filtered_rows),
failed_job_names,
paused_count,
),
records=records,
shared_sections=shared_sections,
filters=list(self.filter_options),
active_filter_key=active_filter,
search=search_context,
)
JOBS_BROWSER_ADAPTER = JobsBrowserAdapter()
@@ -0,0 +1,227 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from cli.tui.copy import TUI_COPY
from cli.tui.search import PageFilterOption, PageSearchContext
from cli.utils import SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class DetailSectionSnapshot:
"""
TUI 详情页单个分区快照。
:param title: 分区标题
:param status: 分区状态
:param lines: 分区正文文本行
"""
title: str
status: str
lines: list[str]
@dataclass(frozen=True)
class DetailPageSnapshot:
"""
TUI 详情页聚合快照。
:param title: 页面标题
:param subtitle: 页面副标题
:param sections: 页面分区列表
:param search: 当前页面搜索上下文
"""
title: str
subtitle: str
sections: list[DetailSectionSnapshot]
search: PageSearchContext | None = None
@dataclass(frozen=True)
class BrowserRecordSnapshot:
"""
TUI 浏览页单条记录快照。
:param key: 记录唯一标识
:param title: 记录标题
:param status: 记录状态
:param summary: 记录摘要
:param metadata_lines: 记录元信息文本行
:param detail_sections: 记录联动详情分区列表
"""
key: str
title: str
status: str
summary: str
metadata_lines: list[str]
detail_sections: list[DetailSectionSnapshot]
detail_loader: Callable[[], list[DetailSectionSnapshot]] | None = None
_cached_detail_sections: tuple[DetailSectionSnapshot, ...] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def resolve_detail_sections(self) -> list[DetailSectionSnapshot]:
"""
获取当前记录详情分区,必要时按需加载并缓存。
:return: 详情分区列表
"""
if self.detail_loader is None:
return self.detail_sections
if self._cached_detail_sections is None:
object.__setattr__(self, '_cached_detail_sections', tuple(self.detail_loader()))
return list(self._cached_detail_sections)
@dataclass(frozen=True)
class BrowserPageSnapshot:
"""
TUI 浏览页聚合快照。
:param title: 页面标题
:param subtitle: 页面副标题
:param records: 可浏览记录列表
:param shared_sections: 全局共享分区列表
:param filters: 页面可用筛选项
:param active_filter_key: 当前激活筛选键
:param search: 当前页面搜索上下文
"""
title: str
subtitle: str
records: list[BrowserRecordSnapshot]
shared_sections: list[DetailSectionSnapshot]
filters: list[PageFilterOption] = field(default_factory=list)
active_filter_key: str | None = None
search: PageSearchContext | None = None
class TuiAdapterModelRenderService:
"""
TUI 适配层模型渲染服务。
该对象集中封装结果负载摘要提取,以及空态、加载态、失败态文本行
的统一构建逻辑,避免这些模块级辅助函数继续散落扩张。
"""
@staticmethod
def extract_payload_message(payload: dict[str, object] | None) -> str:
"""
提取结果负载中的可读消息。
:param payload: 标准结果负载
:return: 摘要消息文本
"""
if not isinstance(payload, dict):
return '-'
if payload.get('message'):
return SHELL_TEXT_FORMATTER.truncate_text(payload.get('message', '-'), 120)
if payload.get('error'):
return SHELL_TEXT_FORMATTER.truncate_text(payload.get('error', '-'), 120)
return '-'
@staticmethod
def build_empty_lines(
*,
empty_label: str,
empty_value: str,
detail: str,
suggestion: str = TUI_COPY.build_empty_state_suggestion(),
) -> list[str]:
"""
构建统一的空态文本行。
:param empty_label: 空态主字段名称
:param empty_value: 空态主字段值
:param detail: 空态说明
:param suggestion: 建议操作
:return: 空态文本行列表
"""
return [
TUI_COPY.build_state_section_title('status'),
f'{empty_label}: {empty_value}',
'',
TUI_COPY.build_state_section_title('detail'),
detail,
'',
TUI_COPY.build_state_section_title('suggestion'),
suggestion,
]
@staticmethod
def build_loading_lines(
*,
loading_label: str,
loading_value: str,
detail: str,
suggestion: str = TUI_COPY.build_loading_state_suggestion(),
) -> list[str]:
"""
构建统一的加载中文本行。
:param loading_label: 加载态主字段名称
:param loading_value: 加载态主字段值
:param detail: 加载说明
:param suggestion: 建议操作
:return: 加载态文本行列表
"""
return [
TUI_COPY.build_state_section_title('status'),
f'{loading_label}: {loading_value}',
'',
TUI_COPY.build_state_section_title('detail'),
detail,
'',
TUI_COPY.build_state_section_title('suggestion'),
suggestion,
]
def build_failure_lines(
self,
payload: dict[str, object] | None,
*,
empty_label: str,
empty_value: str,
) -> list[str]:
"""
为失败状态构建统一的可读文本行。
:param payload: 标准结果负载
:param empty_label: 主字段标签
:param empty_value: 主字段兜底值
:return: 失败状态文本行列表
"""
lines = [
TUI_COPY.build_state_section_title('status'),
f'{empty_label}: {empty_value}',
'',
TUI_COPY.build_state_section_title('error'),
]
if not isinstance(payload, dict):
lines.append('结果消息: -')
lines.extend(
['', TUI_COPY.build_state_section_title('suggestion'), TUI_COPY.build_failure_state_suggestion()]
)
return lines
lines.append(f'结果消息: {self.extract_payload_message(payload)}')
detail_lines: list[str] = []
if payload.get('hint'):
detail_lines.append(f'建议提示: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("hint", "-"), 120)}')
if payload.get('error'):
detail_lines.append(f'错误信息: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("error", "-"), 120)}')
if payload.get('stderr'):
detail_lines.append(f'标准错误: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("stderr", "-"), 120)}')
if payload.get('stdout'):
detail_lines.append(f'标准输出: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("stdout", "-"), 120)}')
if detail_lines:
lines.extend(['', TUI_COPY.build_state_section_title('diagnostic'), *detail_lines])
lines.extend(['', TUI_COPY.build_state_section_title('suggestion'), TUI_COPY.build_failure_state_suggestion()])
return lines
TUI_ADAPTER_MODEL_RENDERER = TuiAdapterModelRenderService()
@@ -0,0 +1,482 @@
from dataclasses import dataclass
from typing import Any
from cli.tui.adapters.base import BaseDetailAdapter
from cli.tui.adapters.models import (
TUI_ADAPTER_MODEL_RENDERER,
DetailPageSnapshot,
DetailSectionSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.diagnostics import TUI_DIAGNOSTIC_SERVICE
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class OpsDetailSourcePayloads:
"""
运维详情页原始数据源快照。
:param health_payload: `ops health` 结果
:param ping_db_payload: `ops ping-db` 结果
:param ping_redis_payload: `ops ping-redis` 结果
:param deps_payload: `ops deps` 结果
:param server_payload: `ops server-info` 结果
"""
health_payload: dict[str, Any] | None
ping_db_payload: dict[str, Any] | None
ping_redis_payload: dict[str, Any] | None
deps_payload: dict[str, Any] | None
server_payload: dict[str, Any] | None
class OpsDetailSnapshotCollector:
"""
运维详情页数据采集器。
该对象负责拉取运维详情页所需的多路 CLI 原始结果,
让 `OpsDetailAdapter` 保持详情页编排职责。
"""
def collect(self, env: str) -> OpsDetailSourcePayloads:
"""
采集运维详情页所需原始结果。
:param env: 当前运行环境
:return: 运维详情页原始数据源快照
"""
return OpsDetailSourcePayloads(
health_payload=NESTED_CLI_SUPPORT.run(
'ops',
'health',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
ping_db_payload=NESTED_CLI_SUPPORT.run(
'ops',
'ping-db',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
ping_redis_payload=NESTED_CLI_SUPPORT.run(
'ops',
'ping-redis',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
deps_payload=NESTED_CLI_SUPPORT.run(
'ops',
'deps',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
server_payload=NESTED_CLI_SUPPORT.run(
'ops',
'server-info',
f'--env={env}',
'--output=json',
parse_json=True,
).payload,
)
class OpsSectionBuilder:
"""
运维详情分区构建器。
该构建器负责将运维相关 CLI 结果负载转换为 TUI 详情页分区,
使详情页适配器本体只保留采集与编排职责。
"""
@staticmethod
def build_health_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建运维健康检查分区。
:param payload: `ops health` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict):
return DetailSectionSnapshot(
title='健康检查',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='健康检查', empty_value='不可用'
),
)
database = payload.get('database') if isinstance(payload.get('database'), dict) else {}
redis = payload.get('redis') if isinstance(payload.get('redis'), dict) else {}
return DetailSectionSnapshot(
title='健康检查',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 基础连通性',
f'环境: {payload.get("env", "-")}',
f'数据库: {"正常" if database.get("ok", False) else "异常"}',
f'> {SHELL_TEXT_FORMATTER.truncate_text(database.get("message", "-"), 64)}',
f'Redis: {"正常" if redis.get("ok", False) else "异常"}',
f'> {SHELL_TEXT_FORMATTER.truncate_text(redis.get("message", "-"), 64)}',
],
)
@staticmethod
def build_ping_db_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建数据库探活结果分区。
:param payload: `ops ping-db` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict):
return DetailSectionSnapshot(
title='数据库探活',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='数据库探活', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='数据库探活',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 探活结果',
f'数据库连接: {"正常" if payload.get("ok", False) else "异常"}',
f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
],
)
@staticmethod
def build_ping_redis_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建 Redis 探活结果分区。
:param payload: `ops ping-redis` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict):
return DetailSectionSnapshot(
title='Redis 探活',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='Redis 探活', empty_value='不可用'
),
)
return DetailSectionSnapshot(
title='Redis 探活',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 探活结果',
f'Redis 连接: {"正常" if payload.get("ok", False) else "异常"}',
f'结果摘要: {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
],
)
@staticmethod
def build_dependency_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建运维依赖版本分区。
:param payload: `ops deps` JSON 负载
:return: 分区快照
"""
if not isinstance(payload, dict):
return DetailSectionSnapshot(
title='依赖版本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(payload, empty_label='依赖', empty_value='不可用'),
)
packages = payload.get('packages') if isinstance(payload.get('packages'), dict) else {}
missing_required = payload.get('missingRequired') if isinstance(payload.get('missingRequired'), list) else []
lines = [
'## 总体结论',
f'检查结果: {"通过" if payload.get("ok", False) else "异常"}',
f'缺失核心依赖: {len(missing_required)} 个',
f'> {SHELL_TEXT_FORMATTER.truncate_text(payload.get("message", "-"), 72)}',
'',
'## 关键版本',
]
for package_name in ('python', 'fastapi', 'sqlalchemy', 'redis', 'typer', 'alembic'):
package_payload = packages.get(package_name)
if not isinstance(package_payload, dict):
continue
installed = '已安装' if package_payload.get('installed', False) else '缺失'
lines.append(f'> {package_name} · {installed} · {package_payload.get("version", "-") or "-"}')
if missing_required:
lines.extend(['', '## 缺失项'])
lines.extend(f'> {item}' for item in missing_required[:8])
return DetailSectionSnapshot(
title='依赖版本',
status='ok' if payload.get('ok', False) else 'fail',
lines=lines,
)
@staticmethod
def build_server_section(payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建服务器运行概况分区。
:param payload: `ops server-info` JSON 负载
:return: 分区快照
"""
server = payload.get('server') if isinstance(payload, dict) else None
if not isinstance(server, dict):
return DetailSectionSnapshot(
title='服务器摘要',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='服务器信息', empty_value='不可用'
),
)
sys_info = server.get('sys') if isinstance(server.get('sys'), dict) else {}
cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {}
mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {}
py_info = server.get('py') if isinstance(server.get('py'), dict) else {}
return DetailSectionSnapshot(
title='服务器摘要',
status='ok' if payload.get('ok', False) else 'fail',
lines=[
'## 主机信息',
f'主机名: {sys_info.get("computerName", "-")}',
f'主机 IP: {sys_info.get("computerIp", "-")}',
f'操作系统: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("osName", "-"), 56)}',
f'系统架构: {sys_info.get("osArch", "-")}',
'',
'## 资源负载',
f'CPU 核心: {cpu_info.get("cpuNum", "-")}',
f'CPU 使用率: {cpu_info.get("used", "-")}%',
f'内存总量: {mem_info.get("total", "-")}',
f'内存使用率: {mem_info.get("usage", "-")}%',
'',
'## Python 进程',
f'版本: {py_info.get("version", "-")}',
f'运行时长: {py_info.get("runTime", "-")}',
f'进程内存: {py_info.get("used", "-")} / {py_info.get("total", "-")}',
],
)
def build_disk_section(self, payload: dict[str, Any] | None) -> DetailSectionSnapshot:
"""
构建服务器磁盘样本分区。
:param payload: `ops server-info` JSON 负载
:return: 分区快照
"""
server = payload.get('server') if isinstance(payload, dict) else None
if not isinstance(server, dict):
return DetailSectionSnapshot(
title='磁盘样本',
status='fail',
lines=TUI_ADAPTER_MODEL_RENDERER.build_failure_lines(
payload, empty_label='磁盘样本', empty_value='不可用'
),
)
sys_files = server.get('sysFiles') if isinstance(server.get('sysFiles'), list) else []
lines = TUI_ADAPTER_MODEL_RENDERER.build_empty_lines(
empty_label='磁盘样本',
empty_value='0 个',
detail='当前服务器信息中没有返回磁盘分区样本',
suggestion=TUI_COPY.build_refresh_page_suggestion('运维', '重新采集服务器信息'),
)
section_status = 'info'
if sys_files:
section_status = 'ok'
lines = []
for item in sys_files[:8]:
if not isinstance(item, dict):
continue
lines.extend(
[
f'## {SHELL_TEXT_FORMATTER.truncate_text(item.get("dirName", "-"), 36)}',
(
f'> 已用 {item.get("used", "-")} / 总量 {item.get("total", "-")} '
f'| 使用率 {item.get("usage", "-")}'
),
f'> 可用 {item.get("free", "-")}',
'',
]
)
if lines and lines[-1] == '':
lines.pop()
return DetailSectionSnapshot(
title='磁盘样本',
status=section_status,
lines=lines,
)
@staticmethod
def build_prod_check_entry_section() -> DetailSectionSnapshot:
"""
构建生产巡检向导入口分区。
:return: 分区快照
"""
return DetailSectionSnapshot(
title='生产巡检入口',
status='info',
lines=TUI_COPY.build_command_hint_lines(
scenario='准备做生产环境巡检或上线前复核时,应先通过向导统一检查数据库、缓存和运行配置。',
command=TUI_COPY.build_cli_command_hint('wizard', 'prod-check', '--output=text'),
guide='向导会继续询问目标环境并输出预览结果,确认后再执行完整巡检。',
),
)
@staticmethod
def build_overview_section(
health_payload: dict[str, Any] | None,
deps_payload: dict[str, Any] | None,
server_payload: dict[str, Any] | None,
) -> DetailSectionSnapshot:
"""
构建运维页总览判断分区。
:param health_payload: `ops health` JSON 负载
:param deps_payload: `ops deps` JSON 负载
:param server_payload: `ops server-info` JSON 负载
:return: 分区快照
"""
health_ok = bool(isinstance(health_payload, dict) and health_payload.get('ok', False))
deps_ok = bool(isinstance(deps_payload, dict) and deps_payload.get('ok', False))
server_ok = bool(isinstance(server_payload, dict) and server_payload.get('ok', False))
missing_required = deps_payload.get('missingRequired') if isinstance(deps_payload, dict) else None
missing_count = len(missing_required) if isinstance(missing_required, list) else 0
server = (
server_payload.get('server')
if isinstance(server_payload, dict) and isinstance(server_payload.get('server'), dict)
else {}
)
cpu_info = server.get('cpu') if isinstance(server.get('cpu'), dict) else {}
mem_info = server.get('mem') if isinstance(server.get('mem'), dict) else {}
status = 'ok'
conclusion = '基础探活与运行依赖正常,可继续查看服务器资源与磁盘样本'
if not health_ok:
status = 'fail'
conclusion = '运维探活存在异常,优先处理数据库或 Redis 连通性问题'
elif not deps_ok or missing_count > 0:
status = 'warn'
conclusion = '运行依赖存在缺口,建议优先补齐核心依赖并确认版本兼容性'
elif not server_ok:
status = 'warn'
conclusion = '服务器信息采集异常,建议重新采集资源与磁盘状态'
return DetailSectionSnapshot(
title='总览判断',
status=status,
lines=[
'## 当前结论',
conclusion,
'',
'## 核心指标',
f'探活状态: {"正常" if health_ok else "异常"}',
f'缺失核心依赖: {missing_count} 个',
f'CPU 使用率: {cpu_info.get("used", "-")}%',
f'内存使用率: {mem_info.get("usage", "-")}%',
'',
'## 建议入口',
'优先关注:健康检查 / 依赖版本 / 服务器摘要 / 生产巡检入口',
],
)
def build_sections(
self,
*,
health_payload: dict[str, Any] | None,
ping_db_payload: dict[str, Any] | None,
ping_redis_payload: dict[str, Any] | None,
deps_payload: dict[str, Any] | None,
server_payload: dict[str, Any] | None,
) -> list[DetailSectionSnapshot]:
"""
构建运维页全部详情分区。
:param health_payload: `ops health` 结果负载
:param ping_db_payload: `ops ping-db` 结果负载
:param ping_redis_payload: `ops ping-redis` 结果负载
:param deps_payload: `ops deps` 结果负载
:param server_payload: `ops server-info` 结果负载
:return: 详情分区列表
"""
return [
self.build_overview_section(health_payload, deps_payload, server_payload),
self.build_health_section(health_payload),
self.build_ping_db_section(ping_db_payload),
self.build_ping_redis_section(ping_redis_payload),
self.build_dependency_section(deps_payload),
self.build_server_section(server_payload),
self.build_disk_section(server_payload),
self.build_prod_check_entry_section(),
]
class OpsDetailAdapter(BaseDetailAdapter):
"""
运维详情页适配器。
该适配器负责采集运维相关 CLI 结果,并委托分区构建器组装为
TUI 详情页快照。
"""
def __init__(
self,
section_builder: OpsSectionBuilder | None = None,
snapshot_collector: OpsDetailSnapshotCollector | None = None,
) -> None:
"""
初始化运维详情页适配器。
:param section_builder: 运维详情分区构建器
:param snapshot_collector: 运维详情页数据采集器
:return: None
"""
super().__init__(
page_title='运维',
search_view_key='ops',
default_suggestions=[
'总览判断',
'健康检查',
'数据库探活',
'Redis 探活',
'依赖版本',
'服务器摘要',
'磁盘样本',
'生产巡检入口',
],
)
self.section_builder = section_builder or OpsSectionBuilder()
self.snapshot_collector = snapshot_collector or OpsDetailSnapshotCollector()
def collect_snapshot(self, env: str, query: str = '') -> DetailPageSnapshot:
"""
采集运维状态页只读快照。
:param env: 当前运行环境
:param query: 当前搜索词
:return: 页面快照
"""
source_payloads = self.snapshot_collector.collect(env)
sections = self.section_builder.build_sections(
health_payload=source_payloads.health_payload,
ping_db_payload=source_payloads.ping_db_payload,
ping_redis_payload=source_payloads.ping_redis_payload,
deps_payload=source_payloads.deps_payload,
server_payload=source_payloads.server_payload,
)
return DetailPageSnapshot(
title='运维',
subtitle=TUI_DIAGNOSTIC_SERVICE.build_ops_diagnostic_subtitle(
source_payloads.health_payload,
source_payloads.deps_payload,
source_payloads.server_payload,
),
sections=self.filter_sections(sections, query),
search=self.resolve_search_context(query),
)
OPS_DETAIL_ADAPTER = OpsDetailAdapter()
+898
View File
@@ -0,0 +1,898 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from textual.app import App
from textual.css.query import NoMatches
from cli.tui.adapters import (
TUI_SNAPSHOT_COLLECTOR_REGISTRY,
BrowserPageSnapshot,
DashboardSnapshot,
DetailPageSnapshot,
)
from cli.tui.copy import TUI_COPY
from cli.tui.keymaps import TUI_KEYMAP_REGISTRY
from cli.tui.screens import BrowserScreen, DashboardScreen, DetailScreen
from cli.tui.widgets import NavigationItem, WorkspaceSidebar
SnapshotCollector = Callable[['ShierfengTuiApp'], BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot]
@dataclass(frozen=True)
class TuiViewSpec:
"""
TUI 视图规格定义。
:param view_key: 视图标识
:param include_query: 是否注入搜索词
:param include_filter: 是否注入筛选键
"""
view_key: str
include_query: bool = False
include_filter: bool = False
@dataclass(frozen=True)
class TuiViewDefinition:
"""
TUI 视图定义。
:param view_key: 视图标识
:param collector: 页面快照采集器
"""
view_key: str
collector: SnapshotCollector
@dataclass(frozen=True)
class TuiViewRegistry:
"""
TUI 视图注册表。
该对象集中维护视图打开、刷新与顺序导航所需的元数据,
避免 `ShierfengTuiApp` 内继续手工维护多份视图映射。
:param definitions: 视图定义映射
:param navigation_items: 导航项列表
"""
definitions: dict[str, TuiViewDefinition]
navigation_items: list[NavigationItem]
@staticmethod
def normalize_view_key(view_key: str) -> str:
"""
规范化视图标识。
:param view_key: 原始视图标识
:return: 规范化后的视图标识
"""
return str(view_key).strip().lower()
def resolve_view_key(self, view_key: str) -> str:
"""
解析可用视图标识,不存在时回退到 dashboard。
:param view_key: 原始视图标识
:return: 可用视图标识
"""
normalized_view_key = self.normalize_view_key(view_key)
if normalized_view_key in self.definitions:
return normalized_view_key
return 'dashboard'
def collect_snapshot(
self, app: 'ShierfengTuiApp', view_key: str
) -> BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot:
"""
采集指定视图的页面快照。
:param app: 当前 TUI 应用实例
:param view_key: 目标视图标识
:return: 页面快照
"""
resolved_view_key = self.resolve_view_key(view_key)
return self.definitions[resolved_view_key].collector(app)
def get_navigation_index(self, view_key: str) -> int:
"""
获取视图在导航列表中的索引。
:param view_key: 目标视图标识
:return: 导航索引
"""
resolved_view_key = self.resolve_view_key(view_key)
return next(
(index for index, item in enumerate(self.navigation_items) if item.view_key == resolved_view_key),
0,
)
def get_relative_view_key(self, view_key: str, offset: int) -> str:
"""
获取相对当前视图偏移后的目标视图标识。
:param view_key: 当前视图标识
:param offset: 偏移量
:return: 目标视图标识
"""
current_index = self.get_navigation_index(view_key)
target_index = (current_index + offset) % len(self.navigation_items)
return self.navigation_items[target_index].view_key
_TUI_VIEW_SPECS: tuple[TuiViewSpec, ...] = (
TuiViewSpec('dashboard'),
TuiViewSpec('app', include_query=True),
TuiViewSpec('ops', include_query=True),
TuiViewSpec('database', include_query=True),
TuiViewSpec('cache', include_query=True),
TuiViewSpec('jobs', include_query=True, include_filter=True),
TuiViewSpec('gen', include_query=True),
TuiViewSpec('configs', include_query=True, include_filter=True),
TuiViewSpec('crypto', include_query=True),
)
_TUI_APP_CSS = """
Screen {
layout: vertical;
background: #04080f;
color: #d8f7ff;
scrollbar-size-horizontal: 1;
scrollbar-size-vertical: 1;
scrollbar-background: #06111b;
scrollbar-background-hover: #0a1723;
scrollbar-background-active: #0d1f2d;
scrollbar-color: #1f6f8d;
scrollbar-color-hover: #38d8ff;
scrollbar-color-active: #6df7ff;
}
Widget {
scrollbar-size-horizontal: 1;
scrollbar-size-vertical: 1;
scrollbar-background: #06111b;
scrollbar-background-hover: #0a1723;
scrollbar-background-active: #0d1f2d;
scrollbar-color: #1f6f8d;
scrollbar-color-hover: #38d8ff;
scrollbar-color-active: #6df7ff;
}
#workspace-shell {
height: 1fr;
background: #050c15;
}
#workspace-main {
padding: 1 2 1 1;
background: #050c15;
border: round #18425d;
margin: 1 1 1 0;
overflow-x: auto;
overflow-y: auto;
scrollbar-size-horizontal: 1;
scrollbar-size-vertical: 1;
scrollbar-background: #06111b;
scrollbar-background-hover: #0a1723;
scrollbar-background-active: #0d1f2d;
scrollbar-color: #1f6f8d;
scrollbar-color-hover: #38d8ff;
scrollbar-color-active: #6df7ff;
}
#workspace-main:focus {
border: heavy #6df7ff;
background: #071320;
}
#detail-body {
height: auto;
margin-top: 1;
border: double #18425d;
background: #06111b;
padding: 1;
overflow-x: auto;
overflow-y: auto;
}
#browser-body {
height: auto;
margin-top: 1;
border: double #18425d;
background: #06111b;
padding: 1;
overflow-x: auto;
overflow-y: auto;
}
#browser-filter-bar {
border: round #163c57;
padding: 1 2;
margin-bottom: 1;
background: #07131f;
color: #d9fbff;
}
#browser-detail-pane {
width: 1fr;
min-width: 48;
border: round #163c57;
padding: 1;
background: #07131f;
overflow-x: auto;
overflow-y: auto;
}
#browser-detail-pane.is-ok {
border: round #22c983;
}
#browser-detail-pane.is-fail {
border: round #ff6b7a;
background: #190d12;
}
#browser-detail-pane.is-warn {
border: round #f2b84b;
background: #171108;
}
#browser-detail-pane.is-info {
border: round #38d8ff;
}
#browser-section-body {
height: auto;
border-top: solid #163c57;
padding-top: 1;
background: #06111b;
overflow-x: auto;
overflow-y: auto;
}
#dashboard-metrics {
grid-size: 4;
grid-gutter: 1 2;
grid-columns: 1fr 1fr 1fr 1fr;
height: auto;
margin-top: 1;
margin-bottom: 1;
padding: 1 0;
}
#dashboard-grid, #detail-grid {
grid-size: 2;
grid-gutter: 1 2;
grid-columns: 1fr 1fr;
height: auto;
margin-top: 1;
}
#detail-grid {
margin-bottom: 1;
}
Footer {
background: #060d16;
color: #b6efff;
border-top: heavy #163c57;
}
FooterKey {
background: #0b2130;
color: #ecfcff;
}
"""
@dataclass(frozen=True)
class TuiBindingSpec:
"""
TUI 快捷键绑定定义。
:param key: 快捷键
:param action: 动作名称
:param label: 展示标签
"""
key: str
action: str
label: str
class TuiBindingBuilder:
"""
TUI 快捷键绑定构建器。
该对象负责统一装配应用级固定绑定和视图跳转绑定,避免 `ShierfengTuiApp`
继续手工维护重复的绑定元组列表。
"""
def build_base_bindings(self) -> list[TuiBindingSpec]:
"""
构建应用级固定绑定。
:return: 绑定定义列表
"""
return [
TuiBindingSpec('q', 'quit', TUI_COPY.build_app_binding_label('quit')),
TuiBindingSpec('s', 'focus_sidebar', TUI_COPY.build_app_binding_label('sidebar')),
TuiBindingSpec('[', 'show_previous_view', TUI_COPY.build_app_binding_label('previous')),
TuiBindingSpec(']', 'show_next_view', TUI_COPY.build_app_binding_label('next')),
TuiBindingSpec('r', 'refresh_current_view', TUI_COPY.build_app_binding_label('refresh')),
]
def build_navigation_bindings(self) -> list[TuiBindingSpec]:
"""
构建页面导航绑定。
:return: 绑定定义列表
"""
return [
TuiBindingSpec(
TUI_KEYMAP_REGISTRY.get_navigation_shortcut(spec.view_key),
f'show_{spec.view_key}',
TUI_COPY.render_view_label(spec.view_key),
)
for spec in _TUI_VIEW_SPECS
]
def build(self) -> list[tuple[str, str, str]]:
"""
构建 Textual 所需的绑定元组列表。
:return: 绑定元组列表
"""
return [
(binding.key, binding.action, binding.label)
for binding in [*self.build_base_bindings(), *self.build_navigation_bindings()]
]
TUI_APP_BINDINGS = TuiBindingBuilder().build()
@dataclass(frozen=True)
class TuiViewRegistryBuilder:
"""
TUI 视图注册表构建器。
该对象负责根据统一规格构建导航项和快照采集定义,避免模块内
继续维护重复的 `lambda collect(...)` 配置块。
"""
def build_navigation_items(self) -> list[NavigationItem]:
"""
构建工作台导航项列表。
:return: 导航项列表
"""
return [
NavigationItem(
view_key,
TUI_COPY.render_view_label(view_key),
shortcut,
TUI_COPY.render_navigation_description(view_key),
)
for view_key, shortcut in TUI_KEYMAP_REGISTRY.navigation_shortcuts.items()
]
def build_collector(self, spec: TuiViewSpec) -> SnapshotCollector:
"""
根据视图规格构建页面快照采集器。
:param spec: 视图规格
:return: 页面快照采集器
"""
def collect(app: 'ShierfengTuiApp') -> BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot:
collect_kwargs: dict[str, str] = {}
if spec.include_filter:
collect_kwargs['filter_key'] = app.get_browser_filter(spec.view_key)
if spec.include_query:
collect_kwargs['query'] = app.get_browser_query(spec.view_key)
return TUI_SNAPSHOT_COLLECTOR_REGISTRY.collect(spec.view_key, app.env, **collect_kwargs)
return collect
def build_definitions(self) -> dict[str, TuiViewDefinition]:
"""
构建视图定义映射。
:return: 视图定义映射
"""
return {spec.view_key: TuiViewDefinition(spec.view_key, self.build_collector(spec)) for spec in _TUI_VIEW_SPECS}
def build(self) -> TuiViewRegistry:
"""
构建 TUI 视图注册表。
:return: 视图注册表
"""
return TuiViewRegistry(
definitions=self.build_definitions(),
navigation_items=self.build_navigation_items(),
)
@dataclass
class TuiViewStateStore:
"""
TUI 视图状态存储。
该对象集中维护页面级动作反馈、浏览页筛选键和搜索词,避免
`ShierfengTuiApp` 本体继续直接操作多份同类字典状态。
:param action_feedback_by_view: 动作反馈映射
:param browser_filter_by_view: 浏览页筛选映射
:param browser_query_by_view: 浏览页搜索词映射
"""
action_feedback_by_view: dict[str, list[str]]
browser_filter_by_view: dict[str, str]
browser_query_by_view: dict[str, str]
@classmethod
def create_default(cls) -> 'TuiViewStateStore':
"""
创建默认视图状态存储。
:return: 视图状态存储
"""
return cls(
action_feedback_by_view={},
browser_filter_by_view={'jobs': 'all', 'configs': 'all'},
browser_query_by_view={},
)
def remember_action_feedback(self, view_key: str, lines: list[str]) -> None:
"""
记录指定页面最近一次动作反馈。
:param view_key: 页面视图标识
:param lines: 反馈文本行
:return: None
"""
self.action_feedback_by_view[view_key] = list(lines)
def get_action_feedback_lines(self, view_key: str) -> list[str]:
"""
读取指定页面最近一次动作反馈。
:param view_key: 页面视图标识
:return: 反馈文本行
"""
return list(self.action_feedback_by_view.get(view_key, []))
def remember_browser_filter(self, view_key: str, filter_key: str) -> None:
"""
记录指定浏览页当前筛选键。
:param view_key: 页面视图标识
:param filter_key: 当前筛选键
:return: None
"""
self.browser_filter_by_view[view_key] = filter_key
def get_browser_filter(self, view_key: str, default: str = 'all') -> str:
"""
读取指定浏览页当前筛选键。
:param view_key: 页面视图标识
:param default: 默认筛选键
:return: 当前筛选键
"""
return str(self.browser_filter_by_view.get(view_key, default) or default)
def remember_browser_query(self, view_key: str, query: str) -> None:
"""
记录指定浏览页当前搜索词。
:param view_key: 页面视图标识
:param query: 当前搜索词
:return: None
"""
normalized_query = str(query or '').strip()
if normalized_query:
self.browser_query_by_view[view_key] = normalized_query
return
self.browser_query_by_view.pop(view_key, None)
def get_browser_query(self, view_key: str, default: str = '') -> str:
"""
读取指定浏览页当前搜索词。
:param view_key: 页面视图标识
:param default: 默认搜索词
:return: 当前搜索词
"""
return str(self.browser_query_by_view.get(view_key, default) or default)
TUI_VIEW_REGISTRY = TuiViewRegistryBuilder().build()
NAVIGATION_ITEMS = TUI_VIEW_REGISTRY.navigation_items
@dataclass(frozen=True)
class TuiScreenFactory:
"""
TUI 工作台页面工厂。
该对象负责把页面快照转换为具体 screen 实例,避免 `ShierfengTuiApp`
同时承担状态协调和 screen 构建职责。
"""
@staticmethod
def build_refresh_timestamp() -> str:
"""
构建当前页面刷新时间文本。
:return: 格式化后的刷新时间
"""
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def build(
self,
*,
snapshot: BrowserPageSnapshot | DashboardSnapshot | DetailPageSnapshot,
env: str,
active_view: str,
navigation_items: list[NavigationItem],
action_feedback_lines: list[str],
) -> BrowserScreen | DashboardScreen | DetailScreen:
"""
根据快照类型构建对应的工作台页面。
:param snapshot: 页面快照
:param env: 当前运行环境
:param active_view: 当前激活视图
:param navigation_items: 导航项列表
:param action_feedback_lines: 当前视图动作反馈
:return: 对应的页面对象
"""
refreshed_at = self.build_refresh_timestamp()
if isinstance(snapshot, DashboardSnapshot):
return DashboardScreen(
snapshot,
env=env,
active_view=active_view,
navigation_items=navigation_items,
refreshed_at=refreshed_at,
)
if isinstance(snapshot, BrowserPageSnapshot):
return BrowserScreen(
snapshot,
env=env,
active_view=active_view,
navigation_items=navigation_items,
refreshed_at=refreshed_at,
action_feedback_lines=action_feedback_lines,
)
return DetailScreen(
snapshot,
env=env,
active_view=active_view,
navigation_items=navigation_items,
refreshed_at=refreshed_at,
)
@dataclass
class TuiScreenNavigator:
"""
TUI 工作台页面导航器。
该对象负责处理首屏展示与后续切屏差异,让应用壳更专注于状态切换。
:param app: 当前 TUI 应用实例
:param initialized: 工作台屏幕是否已初始化
"""
app: 'ShierfengTuiApp'
initialized: bool = False
def show(self, screen: BrowserScreen | DashboardScreen | DetailScreen) -> None:
"""
显示工作台页面,并兼容首屏初始化与后续切屏。
Textual 在默认根屏阶段直接调用 `switch_screen()` 会触发内部回调栈异常,
因此首次展示页面需要使用 `push_screen()` 建立活动屏,后续再改用
`switch_screen()` 做同层页面替换。
:param screen: 待显示的页面对象
:return: None
"""
if not self.initialized:
self.app.push_screen(screen)
self.initialized = True
return
self.app.switch_screen(screen)
@dataclass(frozen=True)
class TuiViewOpeningCoordinator:
"""
TUI 视图打开协调器。
该对象负责串联视图解析、快照采集、screen 构建与切屏展示,
让 `ShierfengTuiApp` 更专注于状态暴露与 Textual action 桥接。
:param view_registry: 视图注册表
:param screen_factory: 工作台页面工厂
:param navigation_items: 导航项列表
"""
view_registry: TuiViewRegistry
screen_factory: TuiScreenFactory
navigation_items: list[NavigationItem]
def show(self, app: 'ShierfengTuiApp', view_key: str) -> None:
"""
切换并展示指定视图。
:param app: 当前 TUI 应用实例
:param view_key: 目标视图标识
:return: None
"""
resolved_view_key = self.view_registry.resolve_view_key(view_key)
app.current_view = resolved_view_key
snapshot = self.view_registry.collect_snapshot(app, resolved_view_key)
screen = self.screen_factory.build(
snapshot=snapshot,
env=app.env,
active_view=app.current_view,
navigation_items=self.navigation_items,
action_feedback_lines=app.get_action_feedback_lines(app.current_view),
)
app.screen_navigator.show(screen)
class ShierfengTuiApp(App[None]):
"""
ShiErFeng CLI Textual 应用。
:param env: 当前运行环境
"""
CSS = _TUI_APP_CSS
BINDINGS = TUI_APP_BINDINGS
def __init__(self, env: str) -> None:
"""
初始化 TUI 应用实例。
:param env: 当前运行环境
:return: None
"""
self.env = env
self.current_view = 'dashboard'
self.view_state_store = TuiViewStateStore.create_default()
self.screen_factory = TuiScreenFactory()
self.screen_navigator = TuiScreenNavigator(self)
self.view_opening_coordinator = TuiViewOpeningCoordinator(
view_registry=TUI_VIEW_REGISTRY,
screen_factory=self.screen_factory,
navigation_items=NAVIGATION_ITEMS,
)
super().__init__()
def show_view(self, view_key: str) -> None:
"""
切换并展示指定视图。
:param view_key: 目标视图标识
:return: None
"""
self.view_opening_coordinator.show(self, view_key)
def remember_action_feedback(self, view_key: str, lines: list[str]) -> None:
"""
记录指定页面最近一次动作反馈。
:param view_key: 页面视图标识
:param lines: 反馈文本行
:return: None
"""
self.view_state_store.remember_action_feedback(view_key, lines)
def get_action_feedback_lines(self, view_key: str) -> list[str]:
"""
读取指定页面最近一次动作反馈。
:param view_key: 页面视图标识
:return: 反馈文本行
"""
return self.view_state_store.get_action_feedback_lines(view_key)
def remember_browser_filter(self, view_key: str, filter_key: str) -> None:
"""
记录指定浏览页当前筛选键。
:param view_key: 页面视图标识
:param filter_key: 当前筛选键
:return: None
"""
self.view_state_store.remember_browser_filter(view_key, filter_key)
def get_browser_filter(self, view_key: str, default: str = 'all') -> str:
"""
读取指定浏览页当前筛选键。
:param view_key: 页面视图标识
:param default: 默认筛选键
:return: 当前筛选键
"""
return self.view_state_store.get_browser_filter(view_key, default)
def remember_browser_query(self, view_key: str, query: str) -> None:
"""
记录指定浏览页当前搜索词。
:param view_key: 页面视图标识
:param query: 当前搜索词
:return: None
"""
self.view_state_store.remember_browser_query(view_key, query)
def get_browser_query(self, view_key: str, default: str = '') -> str:
"""
读取指定浏览页当前搜索词。
:param view_key: 页面视图标识
:param default: 默认搜索词
:return: 当前搜索词
"""
return self.view_state_store.get_browser_query(view_key, default)
def on_mount(self) -> None:
"""
在应用启动时载入首页。
:return: None
"""
self.show_view('dashboard')
def open_view(self, view_key: str) -> None:
"""
按视图标识打开对应页面。
:param view_key: 视图标识
:return: None
"""
self.show_view(view_key)
def action_focus_sidebar(self) -> None:
"""
将焦点切换到左侧导航栏。
:return: None
"""
try:
self.screen.query_one(WorkspaceSidebar).focus()
except NoMatches:
return
def action_show_previous_view(self) -> None:
"""
切换到前一个工作台视图。
:return: None
"""
self.open_view(TUI_VIEW_REGISTRY.get_relative_view_key(self.current_view, -1))
def action_show_next_view(self) -> None:
"""
切换到后一个工作台视图。
:return: None
"""
self.open_view(TUI_VIEW_REGISTRY.get_relative_view_key(self.current_view, 1))
def action_refresh_current_view(self) -> None:
"""
刷新当前视图数据。
:return: None
"""
self.show_view(self.current_view)
def action_show_dashboard(self) -> None:
"""
切换到首页视图。
:return: None
"""
self.open_view('dashboard')
def action_show_app(self) -> None:
"""
切换到应用详情视图。
:return: None
"""
self.open_view('app')
def action_show_ops(self) -> None:
"""
切换到运维详情视图。
:return: None
"""
self.open_view('ops')
def action_show_database(self) -> None:
"""
切换到数据库详情视图。
:return: None
"""
self.open_view('database')
def action_show_cache(self) -> None:
"""
切换到缓存浏览视图。
:return: None
"""
self.open_view('cache')
def action_show_jobs(self) -> None:
"""
切换到任务浏览视图。
:return: None
"""
self.open_view('jobs')
def action_show_gen(self) -> None:
"""
切换到代码生成浏览视图。
:return: None
"""
self.open_view('gen')
def action_show_configs(self) -> None:
"""
切换到配置浏览视图。
:return: None
"""
self.open_view('configs')
def action_show_crypto(self) -> None:
"""
切换到传输加密详情视图。
:return: None
"""
self.open_view('crypto')
@dataclass(frozen=True)
class TuiAppRunner:
"""
TUI 应用启动器。
该对象负责按环境创建并运行 Textual 应用实例,使命令入口不再依赖
模块级启动函数。
:param app_factory: TUI 应用工厂
"""
app_factory: Callable[[str], ShierfengTuiApp]
def run(self, env: str) -> None:
"""
启动 TUI 应用。
:param env: 当前运行环境
:return: None
"""
self.app_factory(env).run()
TUI_APP_RUNNER = TuiAppRunner(ShierfengTuiApp)
@@ -0,0 +1,209 @@
from dataclasses import dataclass
from typing import Literal
from cli.tui.copy import TUI_COPY
CapabilityKind = Literal['read_only', 'preview', 'low_risk_action', 'wizard_entry', 'command_hint']
CapabilityScope = Literal['browser', 'detail']
@dataclass(frozen=True)
class TuiCapability:
"""
TUI 页面能力定义。
:param kind: 能力类型
:param slot: 绑定的动作槽位
:param key: 绑定的快捷键
:param label: 展示标签
:param hint_label: 用于顶部提示的简洁标签
:param scope: 能力所在页面范围
"""
kind: CapabilityKind
slot: str
key: str
label: str
hint_label: str
scope: CapabilityScope
@dataclass(frozen=True)
class TuiCapabilitySpec:
"""
TUI 页面能力规格定义。
:param capability_key: 能力标识
:param kind: 能力类型
:param slot: 绑定动作槽位
:param key: 绑定快捷键
"""
capability_key: str
kind: CapabilityKind
slot: str
key: str
_BROWSER_CAPABILITY_SPECS: dict[str, tuple[TuiCapabilitySpec, ...]] = {
'jobs': (
TuiCapabilitySpec('job_run_once', 'low_risk_action', 'primary', 'X'),
TuiCapabilitySpec('job_toggle', 'low_risk_action', 'secondary', 'Z'),
TuiCapabilitySpec('job_sync', 'preview', 'global', 'Y'),
),
'configs': (TuiCapabilitySpec('config_sync', 'low_risk_action', 'global', 'Y'),),
'cache': (
TuiCapabilitySpec('cache_clear_wizard', 'wizard_entry', 'global', 'Y'),
TuiCapabilitySpec('cache_warmup', 'low_risk_action', 'utility', 'W'),
),
'gen': (
TuiCapabilitySpec('gen_export_wizard', 'wizard_entry', 'primary', 'X'),
TuiCapabilitySpec('gen_import_wizard', 'wizard_entry', 'secondary', 'Z'),
TuiCapabilitySpec('gen_export_dry_run', 'preview', 'global', 'Y'),
TuiCapabilitySpec('gen_sync_db', 'low_risk_action', 'utility', 'W'),
),
}
_DETAIL_CAPABILITY_SPECS: dict[str, tuple[TuiCapabilitySpec, ...]] = {
'app': (
TuiCapabilitySpec('app_run', 'wizard_entry', 'primary', 'X'),
TuiCapabilitySpec('app_run_wizard', 'wizard_entry', 'global', 'Y'),
TuiCapabilitySpec('completion_install', 'command_hint', 'utility', 'W'),
),
'database': (
TuiCapabilitySpec('db_upgrade_wizard', 'wizard_entry', 'global', 'Y'),
TuiCapabilitySpec('db_init_dry_run', 'preview', 'utility', 'W'),
),
'ops': (
TuiCapabilitySpec('ops_ping_db', 'low_risk_action', 'primary', 'X'),
TuiCapabilitySpec('ops_ping_redis', 'low_risk_action', 'secondary', 'Z'),
TuiCapabilitySpec('prod_check_wizard', 'wizard_entry', 'global', 'Y'),
),
'crypto': (
TuiCapabilitySpec('crypto_keygen', 'wizard_entry', 'primary', 'X'),
TuiCapabilitySpec('crypto_rotate_dry_run', 'preview', 'global', 'Y'),
),
}
@dataclass(frozen=True)
class TuiCapabilityRegistry:
"""
TUI 页面能力注册表。
该对象集中维护浏览页与详情页的能力描述,避免模块级散落
的字典常量在后续扩展时继续增长。
:param browser_capabilities: 浏览页能力映射
:param detail_capabilities: 详情页能力映射
"""
browser_capabilities: dict[str, tuple[TuiCapability, ...]]
detail_capabilities: dict[str, tuple[TuiCapability, ...]]
@staticmethod
def normalize_view_key(view_key: str) -> str:
"""
规范化页面标识。
:param view_key: 原始页面标识
:return: 规范化后的页面标识
"""
return str(view_key).strip().lower()
def get_browser_capabilities(self, view_key: str) -> tuple[TuiCapability, ...]:
"""
获取浏览页支持的能力列表。
:param view_key: 页面视图标识
:return: 能力列表
"""
return self.browser_capabilities.get(self.normalize_view_key(view_key), ())
def get_detail_capabilities(self, view_key: str) -> tuple[TuiCapability, ...]:
"""
获取详情页支持的能力列表。
:param view_key: 页面视图标识
:return: 能力列表
"""
return self.detail_capabilities.get(self.normalize_view_key(view_key), ())
@dataclass(frozen=True)
class TuiCapabilityRegistryBuilder:
"""
TUI 页面能力注册表构建器。
该对象负责通过统一文案服务构建页面能力定义,避免模块内继续
直接堆叠硬编码的标题和提示标签。
"""
def build_capability(
self,
*,
capability_key: str,
kind: CapabilityKind,
slot: str,
key: str,
scope: CapabilityScope,
) -> TuiCapability:
"""
构建单条能力定义。
:param capability_key: 能力标识
:param kind: 能力类型
:param slot: 绑定动作槽位
:param key: 绑定快捷键
:param scope: 页面范围
:return: 能力定义
"""
return TuiCapability(
kind=kind,
slot=slot,
key=key,
label=TUI_COPY.build_capability_label(capability_key),
hint_label=TUI_COPY.build_capability_hint_label(capability_key),
scope=scope,
)
def build_scope_capabilities(
self,
specs_by_view: dict[str, tuple[TuiCapabilitySpec, ...]],
*,
scope: CapabilityScope,
) -> dict[str, tuple[TuiCapability, ...]]:
"""
根据规格映射构建指定页面范围的能力注册表。
:param specs_by_view: 页面能力规格映射
:param scope: 页面范围
:return: 构建后的能力映射
"""
return {
view_key: tuple(
self.build_capability(
capability_key=spec.capability_key,
kind=spec.kind,
slot=spec.slot,
key=spec.key,
scope=scope,
)
for spec in specs
)
for view_key, specs in specs_by_view.items()
}
def build(self) -> TuiCapabilityRegistry:
"""
构建页面能力注册表。
:return: 能力注册表
"""
return TuiCapabilityRegistry(
browser_capabilities=self.build_scope_capabilities(_BROWSER_CAPABILITY_SPECS, scope='browser'),
detail_capabilities=self.build_scope_capabilities(_DETAIL_CAPABILITY_SPECS, scope='detail'),
)
TUI_CAPABILITY_REGISTRY = TuiCapabilityRegistryBuilder().build()
@@ -0,0 +1,145 @@
from dataclasses import dataclass
from importlib import import_module
from types import ModuleType
import typer
from cli.context import EnvOption
from cli.core import DEFAULT_CORE_SERVICES, CliContextFactory, CliExecutionService
from cli.exit_codes import DEPENDENCY_ERROR
from cli.output import CommandResult
from cli.tui.copy import TUI_COPY
@dataclass(frozen=True)
class TuiDependencyResultFactory:
"""
TUI 依赖结果构建器。
该对象负责统一构建 Textual/Rich 缺失时的降级结果,
避免命令注册层继续直接拼接错误负载。
"""
def build_missing_dependency_result(self) -> CommandResult:
"""
构建 TUI 可选依赖缺失时的提示结果。
:return: 命令结果对象
"""
return CommandResult(
data={
'ok': False,
'message': TUI_COPY.build_missing_dependency_message(),
'hint': TUI_COPY.build_missing_dependency_hint(),
},
exit_code=DEPENDENCY_ERROR,
)
@dataclass(frozen=True)
class TuiAppModuleLoader:
"""
TUI 应用模块加载器。
该对象负责识别缺失的可选依赖,并在命令执行阶段按需导入
`cli.tui.app` 模块。
"""
def is_missing_dependency_error(self, exc: ModuleNotFoundError) -> bool:
"""
判断模块导入异常是否由 TUI 可选依赖缺失触发。
:param exc: 模块导入异常
:return: 是否为 TUI 依赖缺失
"""
missing_module_name = (exc.name or '').split('.', 1)[0]
return missing_module_name in {'textual', 'rich'}
def load(self) -> ModuleType:
"""
惰性导入 TUI 应用模块。
:return: `cli.tui.app` 模块对象
"""
return import_module('cli.tui.app')
@dataclass(frozen=True)
class TuiCommandRegistration:
"""
TUI 命令注册器。
:param context_factory: CLI 上下文工厂
:param execution_service: CLI 执行服务
:param dependency_result_factory: TUI 缺失依赖结果构建器
:param module_loader: TUI 应用模块加载器
"""
context_factory: CliContextFactory
execution_service: CliExecutionService
dependency_result_factory: TuiDependencyResultFactory
module_loader: TuiAppModuleLoader
def handle_tui_command(self, env: str) -> None:
"""
执行 `shierfeng tui` 命令。
:param env: 当前命令运行环境
:return: None
"""
ctx = self.context_factory.build_readonly(env, 'text')
try:
tui_app_module = self.module_loader.load()
except ModuleNotFoundError as exc:
if self.module_loader.is_missing_dependency_error(exc):
self.execution_service.complete_result(
ctx,
self.dependency_result_factory.build_missing_dependency_result(),
)
return
raise
tui_app_module.TUI_APP_RUNNER.run(env)
def build(self) -> typer.Typer:
"""
构建 `tui` 子应用。
:return: `tui` 子应用
"""
app = typer.Typer(
help=TUI_COPY.build_tui_command_help(),
no_args_is_help=False,
invoke_without_command=True,
context_settings={'help_option_names': ['-h', '--help']},
)
@app.callback()
def tui(
env: EnvOption = 'dev',
) -> None:
"""
进入只读巡检工作台。
:param env: 当前命令运行环境
:return: None
"""
self.handle_tui_command(env)
return app
def register(self, root_cli: typer.Typer) -> None:
"""
向根 Typer 应用注册 `tui` 命令。
:param root_cli: 根 Typer 应用
:return: None
"""
root_cli.add_typer(self.build(), name='tui')
TUI_COMMAND_REGISTRATION = TuiCommandRegistration(
context_factory=DEFAULT_CORE_SERVICES.context_factory,
execution_service=DEFAULT_CORE_SERVICES.execution_service,
dependency_result_factory=TuiDependencyResultFactory(),
module_loader=TuiAppModuleLoader(),
)
@@ -0,0 +1,23 @@
from cli.tui.copy.actions import TuiActionCopyMixin
from cli.tui.copy.navigation import TuiNavigationCopyMixin
from cli.tui.copy.workspace import TuiWorkspaceCopyMixin
class TuiCopyService(TuiNavigationCopyMixin, TuiActionCopyMixin, TuiWorkspaceCopyMixin):
"""
TUI 文案构造服务。
该服务通过组合导航、动作和工作区文案混入对象,统一对外提供
TUI 所需的文案构造能力,避免入口模块继续堆积实现细节。
"""
TUI_COPY = TuiCopyService()
__all__ = [
'TUI_COPY',
'TuiActionCopyMixin',
'TuiCopyService',
'TuiNavigationCopyMixin',
'TuiWorkspaceCopyMixin',
]
@@ -0,0 +1,298 @@
from cli.tui.copy.fragments import (
ACTION_CONSEQUENCE_TEXTS,
ACTION_FEEDBACK_COPY,
ACTION_HINT_COPY,
ACTION_LABELS,
ACTION_NOTIFICATION_COPY,
ACTION_PREVIEW_FIELD_LABELS,
ACTION_PREVIEW_TITLES,
ACTION_PURPOSE_LABELS,
ACTION_RESULT_FIELD_LABELS,
ACTION_SCOPE_LABELS,
CAPABILITY_HINT_LABELS,
CAPABILITY_LABELS,
COMMAND_HINT_TITLES,
GENERAL_COPY,
)
from cli.utils import NESTED_CLI_SUPPORT, SHELL_TEXT_FORMATTER
class TuiActionCopyMixin:
"""
TUI 动作与能力文案混入。
"""
@staticmethod
def build_action_confirm_hint() -> str:
"""
构建动作确认弹窗底部提示文案。
:return: 确认提示文案
"""
return GENERAL_COPY['action_confirm_hint']
@staticmethod
def build_action_confirm_cancel_label() -> str:
"""
构建动作确认弹窗取消按钮文案。
:return: 取消按钮文案
"""
return GENERAL_COPY['action_confirm_cancel_label']
@staticmethod
def build_action_label(action_key: str) -> str:
"""
根据动作标识构建动作标题文案。
:param action_key: 动作标识
:return: 动作标题文案
"""
normalized_key = str(action_key).strip().lower()
return ACTION_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_action_scope_label(action_key: str) -> str:
"""
根据动作标识构建作用范围文案。
:param action_key: 动作标识
:return: 作用范围文案
"""
normalized_key = str(action_key).strip().lower()
return ACTION_SCOPE_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_action_purpose_label(action_key: str) -> str:
"""
根据动作标识构建用途文案。
:param action_key: 动作标识
:return: 用途文案
"""
normalized_key = str(action_key).strip().lower()
return ACTION_PURPOSE_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_action_preview_title(title_key: str) -> str:
"""
根据预览区块标识构建标题文案。
:param title_key: 预览区块标识
:return: 标题文案
"""
normalized_key = str(title_key).strip().lower()
return ACTION_PREVIEW_TITLES.get(normalized_key, normalized_key)
@staticmethod
def build_action_preview_field_label(field_key: str) -> str:
"""
根据预览字段标识构建字段文案。
:param field_key: 预览字段标识
:return: 字段文案
"""
normalized_key = str(field_key).strip().lower()
return ACTION_PREVIEW_FIELD_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_action_result_field_label(field_key: str) -> str:
"""
根据结果字段标识构建字段文案。
:param field_key: 结果字段标识
:return: 字段文案
"""
normalized_key = str(field_key).strip().lower()
return ACTION_RESULT_FIELD_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_action_notification_title() -> str:
"""
构建动作通知标题文案。
:return: 通知标题文案
"""
return ACTION_NOTIFICATION_COPY['title']
@staticmethod
def build_action_unavailable_message() -> str:
"""
构建无可执行动作时的提示文案。
:return: 提示文案
"""
return ACTION_NOTIFICATION_COPY['unavailable_message']
@staticmethod
def build_action_empty_line() -> str:
"""
构建无可执行动作时的空态占位文案。
:return: 空态占位文案
"""
return ACTION_NOTIFICATION_COPY['empty_line']
@staticmethod
def build_action_consequence_text(consequence_key: str) -> str:
"""
根据后果说明标识构建说明文案。
:param consequence_key: 后果说明标识
:return: 后果说明文案
"""
normalized_key = str(consequence_key).strip().lower()
return ACTION_CONSEQUENCE_TEXTS.get(normalized_key, normalized_key)
@staticmethod
def build_capability_label(capability_key: str) -> str:
"""
根据能力标识构建展示标签文案。
:param capability_key: 能力标识
:return: 展示标签文案
"""
normalized_key = str(capability_key).strip().lower()
return CAPABILITY_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_capability_hint_label(capability_key: str) -> str:
"""
根据能力标识构建顶部提示短标签文案。
:param capability_key: 能力标识
:return: 短标签文案
"""
normalized_key = str(capability_key).strip().lower()
return CAPABILITY_HINT_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_action_running_message(label: str) -> str:
"""
构建动作执行中的提示文案。
:param label: 动作标题
:return: 提示文案
"""
return f'正在执行:{label}'
@staticmethod
def build_browser_action_hint_text(view_key: str, interaction_hint: str) -> str:
"""
构建浏览页动作提示文本。
:param view_key: 当前页面视图标识
:param interaction_hint: 通用浏览提示
:return: 动作提示文本
"""
normalized_view = str(view_key).strip().lower()
template = ACTION_HINT_COPY['browser_templates'].get(normalized_view, ACTION_HINT_COPY['browser_default'])
return template.format(interaction_hint=interaction_hint)
@staticmethod
def build_detail_action_hint_text(view_key: str, interaction_hint: str) -> str:
"""
构建详情页动作提示文本。
:param view_key: 当前页面视图标识
:param interaction_hint: 通用浏览提示
:return: 动作提示文本
"""
normalized_view = str(view_key).strip().lower()
template = ACTION_HINT_COPY['detail_templates'].get(normalized_view, ACTION_HINT_COPY['detail_default'])
return template.format(interaction_hint=interaction_hint)
@staticmethod
def build_action_line(key: str, label: str) -> str:
"""
构建单条动作快捷键信息。
:param key: 快捷键
:param label: 动作标题
:return: 文本文案
"""
return f'动作键 [{key}] · {label}'
@staticmethod
def build_capability_hint_text(capability_labels: list[str], interaction_hint: str, *, fallback: str) -> str:
"""
根据能力标签列表构建统一动作提示文本。
:param capability_labels: 动作标签列表
:param interaction_hint: 通用浏览提示
:param fallback: 缺省提示模板
:return: 动作提示文本
"""
if not capability_labels:
return fallback.format(interaction_hint=interaction_hint)
return f'动作键:{" ".join(capability_labels)} | {interaction_hint}'
@staticmethod
def build_action_result_message_line(label: str, value: str) -> str:
"""
构建动作结果详情的单行文案。
:param label: 字段标题
:param value: 字段值
:return: 文本文案
"""
return f'{label}: {value}'
@staticmethod
def build_cli_command_hint(*command_args: str) -> str:
"""
构建 TUI 中展示的 CLI 命令提示文本。
:param command_args: CLI 命令参数
:return: 可直接复制执行的命令文本
"""
return SHELL_TEXT_FORMATTER.format_shell_command(NESTED_CLI_SUPPORT.build_nested_cli_command(*command_args))
@staticmethod
def build_command_hint_lines(
*,
scenario: str,
command: str,
guide: str,
) -> list[str]:
"""
构建统一的命令入口提示文本。
:param scenario: 适用场景说明
:param command: 推荐命令
:param guide: 使用说明
:return: 文本行列表
"""
return [
COMMAND_HINT_TITLES['scenario'],
scenario,
'',
COMMAND_HINT_TITLES['command'],
command,
'',
COMMAND_HINT_TITLES['guide'],
guide,
]
@staticmethod
def build_labeled_value_line(label: str, value: str) -> str:
"""
构建通用的“标签: 值”文案。
:param label: 字段标题
:param value: 字段值
:return: 文本文案
"""
return f'{label}: {value}'
@staticmethod
def build_action_result_toast(label: str, outcome: str, message: str) -> str:
"""
构建动作完成后的通知文案。
:param label: 动作标题
:param outcome: 执行结果
:param message: 结果摘要
:return: 通知文案
"""
return f'{ACTION_FEEDBACK_COPY["toast_prefix"]} · {label} · {outcome} · {message}'
@@ -0,0 +1,339 @@
VIEW_LABELS = {
'dashboard': '总览',
'app': '应用',
'ops': '运维',
'database': '数据库',
'cache': '缓存',
'jobs': '任务',
'gen': '代码生成',
'configs': '参数配置',
'crypto': '加密',
}
STATUS_LABELS = {
'ok': '正常',
'success': '正常',
'healthy': '正常',
'fail': '失败',
'error': '失败',
'down': '失败',
'warn': '警告',
'warning': '警告',
'degraded': '警告',
'info': '信息',
}
GENERAL_COPY = {
'action_confirm_cancel_label': '取消',
'action_confirm_hint': '按键提示 · [Enter] 确认执行 [Esc] 取消返回',
'more_detail_hint': '> 进入对应页面查看更多',
'tui_command_help': '进入只读巡检工作台',
'tui_missing_dependency_hint': '请重新执行 `pip install -r requirements.txt` 或 `pip install -r requirements-pg.txt`',
'tui_missing_dependency_message': '当前环境未安装 TUI 可选依赖,无法启动 `shierfeng tui`',
}
STATE_SUGGESTIONS = {
'dashboard_empty': '按 [R] 刷新,或进入对应页面查看更多上下文',
'dashboard_failure': '按 [R] 刷新,或进入对应页面查看完整诊断信息',
'empty': '可按 [R] 刷新,或切换到其他页面继续查看',
'failure': '可按 [R] 刷新,或切换页面后重新进入',
'loading': '可继续切换其他区域,当前内容加载完成后会自动刷新',
}
PAGE_SUBTITLES = {
'crypto': '查看运行校验、公钥身份和兼容版本信息',
'ops': '查看基础连通性、依赖版本与服务器资源',
}
NAVIGATION_DESCRIPTIONS = {
'dashboard': '全链路态势与关键健康信号',
'app': '环境解析、配置快照、补全诊断与路由摘要',
'ops': '健康检查、依赖版本与服务器资源',
'database': '连接、迁移版本与存储状态',
'cache': 'Redis 容量、热点与命中信号',
'jobs': '定时任务、执行轨迹与失败样本',
'gen': '业务表、字段与生成配置',
'configs': '配置巡检、漂移与异常值',
'crypto': '传输加密校验、公钥身份与兼容版本',
}
DASHBOARD_HERO_COPY = {
'subtitle': '先看整体风险和巡检结论,再进入数据库、缓存、任务等分区继续定位',
'title': '运行驾驶舱',
}
STATUS_PANEL_COPY = {
'empty': '暂无数据',
}
ACTION_LABELS = {
'app_run': '直接启动应用',
'app_run_wizard': '打开启动向导',
'cache_clear_wizard': '打开缓存清理向导',
'cache_warmup': '执行缓存预热',
'completion_install': '安装当前 Shell 补全',
'config_sync': '刷新参数缓存',
'crypto_keygen': '打开密钥生成入口',
'crypto_rotate_dry_run': '执行轮换预演',
'db_init_dry_run': '执行初始化预演',
'db_upgrade_wizard': '打开升级向导',
'gen_export_dry_run': '执行导出预演',
'gen_export_wizard': '打开导出向导',
'gen_import_wizard': '打开导入向导',
'gen_sync_db': '同步数据库表结构',
'job_pause': '暂停任务',
'job_resume': '恢复任务',
'job_run_once': '执行一次任务',
'job_sync': '同步调度配置',
'ops_ping_db': '执行数据库探活',
'ops_ping_redis': '执行 Redis 探活',
'prod_check_wizard': '打开生产巡检向导',
}
ACTION_SCOPE_LABELS = {
'cache_warmup': '系统字典与系统参数基础缓存',
'completion_install': '当前终端活动 Shell 的补全安装流程',
'config_sync': '当前环境全部系统参数',
'db_init_dry_run': '当前环境数据库初始化流程',
'gen_export_dry_run': '当前业务表的代码导出结果预演',
'gen_sync_db': '当前业务表对应的生成配置和物理表结构',
'job_sync': '当前环境调度器配置',
'ops_ping_db': '当前环境数据库连接与基础读写探活',
'ops_ping_redis': '当前环境 Redis 连接与基础命令探活',
}
ACTION_PURPOSE_LABELS = {
'cache_warmup': '重建运行所需的基础缓存内容',
'completion_install': '自动识别当前 Shell,写入补全脚本并按需更新 rc 文件激活命令',
'config_sync': '将数据库中的参数值重新写入 Redis 缓存',
'db_init_dry_run': '仅预演初始化到最新迁移版本,不直接执行真实升级',
'gen_export_dry_run': '先演练导出流程并确认输出模式、模板数量和结果摘要',
'gen_sync_db': '将数据库中的最新字段结构同步回当前业务表配置',
'job_sync': '将最新任务配置同步到调度执行器',
'ops_ping_db': '快速确认当前环境数据库是否可连接并返回基础探活结果',
'ops_ping_redis': '快速确认当前环境 Redis 是否可连接并返回基础命令探活结果',
}
ACTION_PREVIEW_TITLES = {
'command': '## 执行命令',
'consequence': '## 确认后果',
'env': '## 执行环境',
'summary': '## 动作信息',
}
ACTION_PREVIEW_FIELD_LABELS = {
'current_status': '当前状态',
'job': '任务',
'job_id': '任务 ID',
'purpose': '用途',
'scope': '作用范围',
'target_action': '目标动作',
'target_env': '目标环境',
}
ACTION_RESULT_FIELD_LABELS = {
'count': '影响数量',
'exit_code': '退出码',
'fail': '失败',
'hint': '建议',
'job_id': '任务 ID',
'name': '动作名称',
'operation': '操作标签',
'outcome': '结果',
'service': '服务反馈',
'success': '成功',
'summary': '摘要',
}
ACTION_CONSEQUENCE_TEXTS = {
'external': '确认后会暂时挂起 TUI,并在当前终端中执行对应命令;命令结束后自动返回工作台。',
'preview': '确认后会立即执行对应 CLI 低风险命令,并按结果刷新当前页面。',
'wizard': '确认后会暂时挂起 TUI,并在当前终端中打开对应向导;向导结束后自动返回工作台。',
}
APP_BINDING_LABELS = {
'next': '下一页',
'previous': '上一页',
'quit': '退出',
'refresh': '刷新',
'sidebar': '侧边栏',
}
INTERNAL_BINDING_LABELS = {
'action_global': '同步动作',
'action_primary': '执行动作',
'action_secondary': '切换状态',
'action_utility': '工具动作',
'clear_search': '清空搜索',
'confirm_cancel': '取消',
'confirm_submit': '确认',
'end': '跳到末尾',
'filter_1': '筛选项 1',
'filter_2': '筛选项 2',
'filter_3': '筛选项 3',
'filter_4': '筛选项 4',
'focus_left': '向左聚焦',
'focus_right': '向右聚焦',
'home': '回到顶部',
'page_down': '下翻页',
'page_up': '上翻页',
'scroll_down': '向下滚动',
'scroll_up': '向上滚动',
'search': '页内搜索',
}
BROWSER_EMPTY_RECORD_COPY = {
'detail': '当前页面没有可浏览记录',
'label': '记录列表',
'summary': '当前页面没有可浏览记录',
'title': '暂无记录数据',
'value': '暂无数据',
}
BROWSER_LOADING_COPY = {
'detail': '正在后台加载当前记录详情,请稍候',
'label': '详情状态',
'title': '详情加载中',
'value': '加载中',
}
DETAIL_EMPTY_SECTION_COPY = {
'detail_page': '当前页面没有可展示的分区详情',
'detail_record': '当前记录没有可展示的分区详情',
'label': '分区详情',
'title': '暂无分区数据',
'value': '暂无内容',
}
CAPABILITY_LABELS = {
'app_run': ACTION_LABELS['app_run'],
'app_run_wizard': ACTION_LABELS['app_run_wizard'],
'cache_clear_wizard': ACTION_LABELS['cache_clear_wizard'],
'cache_warmup': ACTION_LABELS['cache_warmup'],
'completion_install': ACTION_LABELS['completion_install'],
'config_sync': ACTION_LABELS['config_sync'],
'crypto_keygen': ACTION_LABELS['crypto_keygen'],
'crypto_rotate_dry_run': ACTION_LABELS['crypto_rotate_dry_run'],
'db_init_dry_run': ACTION_LABELS['db_init_dry_run'],
'db_upgrade_wizard': ACTION_LABELS['db_upgrade_wizard'],
'gen_export_dry_run': ACTION_LABELS['gen_export_dry_run'],
'gen_export_wizard': ACTION_LABELS['gen_export_wizard'],
'gen_import_wizard': ACTION_LABELS['gen_import_wizard'],
'gen_sync_db': ACTION_LABELS['gen_sync_db'],
'job_run_once': ACTION_LABELS['job_run_once'],
'job_toggle': '暂停/恢复任务',
'job_sync': ACTION_LABELS['job_sync'],
'ops_ping_db': ACTION_LABELS['ops_ping_db'],
'ops_ping_redis': ACTION_LABELS['ops_ping_redis'],
'prod_check_wizard': ACTION_LABELS['prod_check_wizard'],
}
CAPABILITY_HINT_LABELS = {
'app_run': '直接启动',
'app_run_wizard': '打开启动向导',
'cache_clear_wizard': '清理向导',
'cache_warmup': '执行缓存预热',
'completion_install': '安装补全',
'config_sync': '刷新参数缓存',
'crypto_keygen': '密钥生成',
'crypto_rotate_dry_run': '执行轮换预演',
'db_init_dry_run': '初始化预演',
'db_upgrade_wizard': '打开升级向导',
'gen_export_dry_run': '导出预演',
'gen_export_wizard': '导出向导',
'gen_import_wizard': '导入向导',
'gen_sync_db': '同步表结构',
'job_run_once': '执行一次',
'job_toggle': '暂停/恢复',
'job_sync': '同步调度',
'ops_ping_db': '数据库探活',
'ops_ping_redis': 'Redis 探活',
'prod_check_wizard': '打开生产巡检向导',
}
WORKSPACE_LABELS = {
'current_section': '当前分区',
'menu': '菜单',
'overview': '概览',
'record': '记录',
'section': '分区',
'shortcut': '快捷键',
'status': '状态',
}
WORKSPACE_TITLES = {
'action_feedback': '【执行反馈】',
'detail_content': '详情内容',
'key_fields': '【关键字段】',
'key_info': '【关键信息】',
}
WORKSPACE_EMPTY_TEXTS = {
'actions': '暂无可执行动作',
'detail': '暂无详情',
'key_info': '暂无关键信息',
}
STATE_SECTION_TITLES = {
'detail': '## 补充说明',
'diagnostic': '## 诊断线索',
'error': '## 错误摘要',
'status': '## 当前状态',
'suggestion': '## 下一步',
}
ACTION_NOTIFICATION_COPY = {
'empty_line': '当前记录没有可执行的低风险动作',
'title': 'TUI 动作',
'unavailable_message': '当前页面没有可执行的对应快捷动作',
}
BROWSER_ACTION_PANEL_COPY = {
'action_section_title': '## 可执行动作',
'operation_section_title': '## 浏览操作',
'recent_action_section_title': '## 最近动作反馈',
}
COMMAND_HINT_TITLES = {
'command': '## 推荐命令',
'guide': '## 使用说明',
'scenario': '## 适用场景',
}
SIGNAL_RAIL_COPY = {
'empty': '暂无实时信号',
'title': '系统信号带',
}
METRIC_COPY = {
'hint_label': '说明',
'label': '指标',
'value_label': '数值',
}
ACTION_HINT_COPY = {
'browser_default': '当前页面以浏览为主。{interaction_hint}',
'browser_templates': {
'cache': '建议先核对 Redis 概览与键值样本,再决定预热或进入清理向导。{interaction_hint}',
'configs': '建议先看高风险配置,再决定是否刷新参数缓存。{interaction_hint}',
'gen': '建议先看生成前校验、同步预检查与代码预览,再决定是否进入导入向导、同步表结构或进入导出向导。{interaction_hint}',
'jobs': '建议先看失败聚合,再决定执行一次、暂停恢复或同步调度。{interaction_hint}',
},
'detail_default': '当前页面以查看分区为主。{interaction_hint}',
'detail_templates': {
'app': '建议先确认环境映射、应用配置、启动前检查、补全诊断和路由状态,再决定是安装补全、直接启动应用还是进入启动向导。{interaction_hint}',
'crypto': '建议先确认运行校验、公钥身份和兼容版本,再决定是否生成新密钥或执行轮换预演。{interaction_hint}',
'database': '建议先确认 revision、Heads 和历史链路,再决定是否执行初始化预演或进入升级向导。{interaction_hint}',
'ops': '建议先确认数据库探活、Redis 探活、依赖版本和服务器资源,再决定是否进入生产巡检。{interaction_hint}',
},
'operation_hint_lines': [
'> [←/→] 切换焦点',
'> [J/K] 向上/向下滚动',
'> [PgUp/PgDn] 整页滚动',
'> [Home/End] 跳到首尾',
],
}
ACTION_FEEDBACK_COPY = {
'toast_prefix': '动作结果',
}
@@ -0,0 +1,133 @@
from cli.tui.copy.fragments import (
APP_BINDING_LABELS,
GENERAL_COPY,
INTERNAL_BINDING_LABELS,
NAVIGATION_DESCRIPTIONS,
STATUS_LABELS,
VIEW_LABELS,
)
class TuiNavigationCopyMixin:
"""
TUI 导航与基础标签文案混入。
"""
@staticmethod
def render_view_label(view_key: str) -> str:
"""
将内部视图标识转换为中文显示名称。
:param view_key: 视图标识
:return: 中文视图名称
"""
normalized_view = str(view_key).strip().lower()
return VIEW_LABELS.get(normalized_view, view_key)
@staticmethod
def render_status_label(status: str, fallback: str = '信息') -> str:
"""
将状态值转换为中文显示文本。
:param status: 原始状态值
:param fallback: 缺失时兜底文本
:return: 中文状态文本
"""
normalized_status = str(status).strip().lower()
if not normalized_status:
return fallback
return STATUS_LABELS.get(normalized_status, str(status).strip().upper())
@staticmethod
def render_status_code(status: str) -> str:
"""
将状态值转换为控制台风格状态码。
:param status: 原始状态值
:return: 状态码文本
"""
normalized_status = str(status).strip().lower()
status_code_mapping = {
'ok': 'SYS.OK',
'success': 'SYS.OK',
'healthy': 'SYS.OK',
'fail': 'SYS.FAIL',
'error': 'SYS.FAIL',
'down': 'SYS.FAIL',
'warn': 'SYS.WARN',
'warning': 'SYS.WARN',
'degraded': 'SYS.WARN',
'info': 'SYS.INFO',
}
return status_code_mapping.get(normalized_status, f'SYS.{normalized_status.upper() or "INFO"}')
@staticmethod
def render_navigation_description(view_key: str) -> str:
"""
获取指定视图的导航说明。
:param view_key: 视图标识
:return: 导航说明
"""
normalized_view = str(view_key).strip().lower()
return NAVIGATION_DESCRIPTIONS.get(normalized_view, '')
@staticmethod
def build_tui_command_help() -> str:
"""
构建 `shierfeng tui` 命令帮助文案。
:return: 命令帮助文案
"""
return GENERAL_COPY['tui_command_help']
@staticmethod
def build_missing_dependency_message() -> str:
"""
构建 TUI 可选依赖缺失时的提示消息。
:return: 缺依赖提示消息
"""
return GENERAL_COPY['tui_missing_dependency_message']
@staticmethod
def build_missing_dependency_hint() -> str:
"""
构建 TUI 可选依赖缺失时的恢复建议。
:return: 恢复建议文案
"""
return GENERAL_COPY['tui_missing_dependency_hint']
@staticmethod
def build_app_binding_label(binding_key: str) -> str:
"""
根据绑定标识构建应用级快捷键标签文案。
:param binding_key: 绑定标识
:return: 快捷键标签文案
"""
normalized_key = str(binding_key).strip().lower()
return APP_BINDING_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_confirm_binding_label(binding_key: str) -> str:
"""
根据确认弹窗绑定标识构建快捷键标签文案。
:param binding_key: 绑定标识
:return: 快捷键标签文案
"""
normalized_key = str(binding_key).strip().lower()
return INTERNAL_BINDING_LABELS.get(f'confirm_{normalized_key}', normalized_key)
@staticmethod
def build_internal_binding_label(binding_key: str) -> str:
"""
根据内部交互绑定标识构建快捷键标签文案。
:param binding_key: 绑定标识
:return: 快捷键标签文案
"""
normalized_key = str(binding_key).strip().lower()
return INTERNAL_BINDING_LABELS.get(normalized_key, normalized_key)
@@ -0,0 +1,593 @@
from cli.tui.copy.fragments import (
ACTION_HINT_COPY,
BROWSER_ACTION_PANEL_COPY,
BROWSER_EMPTY_RECORD_COPY,
BROWSER_LOADING_COPY,
DASHBOARD_HERO_COPY,
DETAIL_EMPTY_SECTION_COPY,
GENERAL_COPY,
METRIC_COPY,
SIGNAL_RAIL_COPY,
STATE_SECTION_TITLES,
STATE_SUGGESTIONS,
STATUS_PANEL_COPY,
WORKSPACE_EMPTY_TEXTS,
WORKSPACE_LABELS,
WORKSPACE_TITLES,
)
class TuiWorkspaceCopyMixin:
"""
TUI 工作区与状态文案混入。
"""
@staticmethod
def build_workspace_label(label_key: str) -> str:
"""
根据工作台标签标识构建文案。
:param label_key: 标签标识
:return: 标签文案
"""
normalized_key = str(label_key).strip().lower()
return WORKSPACE_LABELS.get(normalized_key, normalized_key)
@staticmethod
def build_workspace_title(title_key: str) -> str:
"""
根据工作台标题标识构建文案。
:param title_key: 标题标识
:return: 标题文案
"""
normalized_key = str(title_key).strip().lower()
return WORKSPACE_TITLES.get(normalized_key, normalized_key)
@staticmethod
def build_workspace_empty_text(text_key: str) -> str:
"""
根据工作台空态标识构建文案。
:param text_key: 空态标识
:return: 空态文案
"""
normalized_key = str(text_key).strip().lower()
return WORKSPACE_EMPTY_TEXTS.get(normalized_key, normalized_key)
@staticmethod
def build_status_panel_empty_text() -> str:
"""
构建状态面板空态提示文案。
:return: 空态提示文案
"""
return STATUS_PANEL_COPY['empty']
@staticmethod
def build_signal_rail_empty_text() -> str:
"""
构建信号带空态提示文案。
:return: 空态提示文案
"""
return SIGNAL_RAIL_COPY['empty']
@staticmethod
def build_more_detail_hint() -> str:
"""
构建查看更多提示文案。
:return: 提示文案
"""
return GENERAL_COPY['more_detail_hint']
@staticmethod
def build_empty_state_suggestion() -> str:
"""
构建通用空态建议文案。
:return: 建议文案
"""
return STATE_SUGGESTIONS['empty']
@staticmethod
def build_loading_state_suggestion() -> str:
"""
构建通用加载态建议文案。
:return: 建议文案
"""
return STATE_SUGGESTIONS['loading']
@staticmethod
def build_failure_state_suggestion() -> str:
"""
构建通用失败态建议文案。
:return: 建议文案
"""
return STATE_SUGGESTIONS['failure']
@staticmethod
def build_dashboard_failure_suggestion() -> str:
"""
构建首页失败态建议文案。
:return: 建议文案
"""
return STATE_SUGGESTIONS['dashboard_failure']
@staticmethod
def build_dashboard_empty_suggestion() -> str:
"""
构建首页空态建议文案。
:return: 建议文案
"""
return STATE_SUGGESTIONS['dashboard_empty']
@staticmethod
def build_state_section_title(title_key: str) -> str:
"""
根据状态区块标识构建标题文案。
:param title_key: 区块标识
:return: 标题文案
"""
normalized_key = str(title_key).strip().lower()
return STATE_SECTION_TITLES.get(normalized_key, normalized_key)
@staticmethod
def build_dashboard_hero_title() -> str:
"""
构建首页 Hero 标题文案。
:return: 标题文案
"""
return DASHBOARD_HERO_COPY['title']
@staticmethod
def build_dashboard_hero_subtitle() -> str:
"""
构建首页 Hero 副标题文案。
:return: 副标题文案
"""
return DASHBOARD_HERO_COPY['subtitle']
@staticmethod
def build_browser_empty_record_copy(field_key: str) -> str:
"""
根据字段标识构建浏览页空记录文案。
:param field_key: 字段标识
:return: 文案
"""
normalized_key = str(field_key).strip().lower()
return BROWSER_EMPTY_RECORD_COPY.get(normalized_key, normalized_key)
@staticmethod
def build_browser_loading_copy(field_key: str) -> str:
"""
根据字段标识构建浏览页加载占位文案。
:param field_key: 字段标识
:return: 文案
"""
normalized_key = str(field_key).strip().lower()
return BROWSER_LOADING_COPY.get(normalized_key, normalized_key)
@staticmethod
def build_detail_empty_section_copy(field_key: str) -> str:
"""
根据字段标识构建详情页空态文案。
:param field_key: 字段标识
:return: 文案
"""
normalized_key = str(field_key).strip().lower()
return DETAIL_EMPTY_SECTION_COPY.get(normalized_key, normalized_key)
@staticmethod
def build_refresh_page_suggestion(page_name: str, detail: str) -> str:
"""
构建详情页场景下的统一建议操作文案。
:param page_name: 页面名称
:param detail: 后续动作描述
:return: 建议文案
"""
return f'可按 [R] 刷新,或进入{page_name}页面{detail}'
@staticmethod
def build_dashboard_page_suggestion(page_name: str, detail: str) -> str:
"""
构建首页总览面板场景下的统一建议操作文案。
:param page_name: 页面名称
:param detail: 后续动作描述
:return: 建议文案
"""
return f'按 [R] 刷新,或进入{page_name}页面{detail}'
@staticmethod
def build_unavailable_record_title(resource_name: str) -> str:
"""
构建浏览页失败兜底记录标题。
:param resource_name: 资源名称
:return: 标题文本
"""
return f'{resource_name}数据暂不可用'
@staticmethod
def build_load_failure_section_title(resource_name: str) -> str:
"""
构建浏览页失败兜底分区标题。
:param resource_name: 资源名称
:return: 分区标题
"""
return f'{resource_name}加载失败'
@staticmethod
def build_empty_record_title(resource_name: str) -> str:
"""
构建浏览页空记录标题。
:param resource_name: 资源名称
:return: 标题文本
"""
return f'暂无{resource_name}'
@staticmethod
def build_unavailable_subtitle(resource_name: str, message: str) -> str:
"""
构建浏览页数据不可用副标题。
:param resource_name: 资源名称
:param message: 错误摘要
:return: 副标题文本
"""
return f'{resource_name}数据不可用:{message}'
@staticmethod
def build_empty_record_summary(detail: str) -> str:
"""
构建浏览页空记录摘要。
:param detail: 摘要说明
:return: 摘要文本
"""
return detail
@staticmethod
def build_loaded_collection_subtitle(count: int, unit: str, detail: str) -> str:
"""
构建浏览页“已加载”副标题。
:param count: 数量
:param unit: 单位
:param detail: 补充说明
:return: 副标题文本
"""
return f'已加载 {count} {unit},{detail}'
@staticmethod
def build_summary_with_message(summary: str, message: str) -> str:
"""
构建带结果摘要补充的副标题。
:param summary: 主摘要
:param message: 结果消息
:return: 副标题文本
"""
return f'{summary} | {message}'
@staticmethod
def build_count_detail_subtitle(prefix: str, count: int, unit: str, suffix: str) -> str:
"""
构建带数量的详情页副标题。
:param prefix: 前缀文本
:param count: 数量
:param unit: 数量单位
:param suffix: 后缀文本
:return: 副标题文本
"""
return f'{prefix} {count} {unit}{suffix}'
@staticmethod
def build_value_detail_subtitle(prefix: str, value: str, suffix: str) -> str:
"""
构建带单值信息的详情页副标题。
:param prefix: 前缀文本
:param value: 值文本
:param suffix: 后缀文本
:return: 副标题文本
"""
return f'{prefix} {value}{suffix}'
@staticmethod
def build_workspace_hero_lines(
*,
view_label: str,
title: str,
subtitle: str,
env: str,
summary: str,
refreshed_at: str,
shortcut_hint: str,
) -> list[str]:
"""
构建工作区顶部摘要文本行。
:param view_label: 当前页面显示名称
:param title: 页面标题
:param subtitle: 页面副标题
:param env: 当前环境
:param summary: 页面摘要
:param refreshed_at: 刷新时间
:param shortcut_hint: 快捷键提示
:return: 文本行列表
"""
return [
f'[{view_label} 控制台]',
title,
subtitle,
'',
f'运行环境 · {env}',
f'当前页面 · {view_label}',
f'运行摘要 · {summary}',
f'操作提示 · {shortcut_hint}',
f'最近刷新 · {refreshed_at}',
]
@staticmethod
def build_navigation_item_lines(index: int, label: str, shortcut: str, description: str) -> list[str]:
"""
构建左侧导航项文案。
:param index: 导航索引
:param label: 导航标题
:param shortcut: 快捷键
:param description: 导航说明
:return: 文本行列表
"""
return [
f'▌ {WORKSPACE_LABELS["menu"]} {index + 1:02d} · {label}',
f'│ {WORKSPACE_LABELS["shortcut"]} [{shortcut.upper()}] · {description}',
]
@staticmethod
def build_section_item_lines(index: int, title: str, status_badge: str, preview: str) -> list[str]:
"""
构建详情页分区导航项文案。
:param index: 分区索引
:param title: 分区标题
:param status_badge: 状态徽标
:param preview: 预览文本
:return: 文本行列表
"""
return [
f'▌ {WORKSPACE_LABELS["section"]} {index + 1:02d} · {title}',
f'│ {status_badge} {preview}',
]
@staticmethod
def build_record_item_lines(index: int, title: str, status_badge: str, summary: str) -> list[str]:
"""
构建浏览页记录导航项文案。
:param index: 记录索引
:param title: 记录标题
:param status_badge: 状态徽标
:param summary: 记录摘要
:return: 文本行列表
"""
return [
f'▌ {WORKSPACE_LABELS["record"]} {index + 1:02d} · {title}',
f'│ {status_badge} {summary}',
]
@staticmethod
def build_section_detail_lines(title: str, status_label: str, status_badge: str, body: str) -> list[str]:
"""
构建详情页右侧分区内容文案。
:param title: 分区标题
:param status_label: 分区状态
:param status_badge: 状态徽标
:param body: 主体文本
:return: 文本行列表
"""
return [
f'{status_badge} {title}',
f'{WORKSPACE_LABELS["status"]} · {status_label}',
'',
body,
]
@staticmethod
def build_browser_action_panel_lines(action_lines: list[str], feedback_lines: list[str]) -> list[str]:
"""
构建浏览页动作面板文案。
:param action_lines: 可执行动作文本
:param feedback_lines: 最近动作反馈文本
:return: 文本行列表
"""
lines = [
BROWSER_ACTION_PANEL_COPY['action_section_title'],
*[f'> {line}' for line in action_lines],
'',
BROWSER_ACTION_PANEL_COPY['operation_section_title'],
]
lines.extend(ACTION_HINT_COPY['operation_hint_lines'])
if feedback_lines:
lines.extend(
[
'',
BROWSER_ACTION_PANEL_COPY['recent_action_section_title'],
*[f'> {line}' for line in feedback_lines],
]
)
return lines
@staticmethod
def build_status_panel_text(title: str, status_code: str, status_label: str, body: str) -> str:
"""
构建状态面板渲染文本。
:param title: 面板标题
:param status_code: 状态码
:param status_label: 状态标签
:param body: 面板正文
:return: 渲染文本
"""
return f'{status_code} {title}\n{WORKSPACE_LABELS["status"]} · {status_label}\n\n{body}'
@staticmethod
def build_metric_panel_text(title: str, value: str, status_code: str, status_label: str, hint: str) -> str:
"""
构建指标卡渲染文本。
:param title: 指标标题
:param value: 指标值
:param status_code: 状态码
:param status_label: 状态标签
:param hint: 提示文本
:return: 渲染文本
"""
return (
f'{METRIC_COPY["label"]} · {title}\n'
f'────────────────────────\n'
f'{METRIC_COPY["value_label"]} · {value}\n'
f'{WORKSPACE_LABELS["status"]} · {status_code} / {status_label}\n'
f'{METRIC_COPY["hint_label"]} · {hint}'
)
@staticmethod
def build_signal_rail_text(pulse: str, body: str) -> str:
"""
构建首页信号带渲染文本。
:param pulse: 当前脉冲字符
:param body: 主体文本
:return: 渲染文本
"""
return '\n'.join(
[
f'{SIGNAL_RAIL_COPY["title"]} {pulse}',
'────────────────────────',
body or SIGNAL_RAIL_COPY['empty'],
]
)
@staticmethod
def build_workspace_header_lines(
*,
env: str,
view_label: str,
timestamp: str,
shortcut_hint: str,
) -> list[str]:
"""
构建工作区顶部状态栏文本行。
:param env: 当前环境
:param view_label: 当前页面显示名称
:param timestamp: 当前时间
:param shortcut_hint: 快捷键提示
:return: 文本行列表
"""
return [
f'ShiErFeng 控制台 · 环境 {env.upper()} · 页面 {view_label}',
f'快捷键 · {shortcut_hint} 时间 {timestamp}',
]
@staticmethod
def build_dashboard_signal_lines(
*,
status_track: str,
fail_count: int,
warn_count: int,
ok_count: int,
total_count: int,
navigation_shortcut_hint: str,
) -> list[str]:
"""
构建首页信号带文案。
:param status_track: 面板状态轨迹
:param fail_count: 失败数量
:param warn_count: 警告数量
:param ok_count: 正常数量
:param total_count: 总数量
:param navigation_shortcut_hint: 页面快捷键提示
:return: 文本行列表
"""
return [
f'面板轨迹 · {status_track or "-"}',
f'风险分布 · 失败 {fail_count:02d} 警告 {warn_count:02d} 正常 {ok_count:02d} 合计 {total_count:02d}',
f'快捷入口 · 页面 {navigation_shortcut_hint}',
]
@staticmethod
def build_dashboard_summary_text(total_count: int, ok_count: int, warn_count: int, fail_count: int) -> str:
"""
构建首页摘要文本。
:param total_count: 面板总数
:param ok_count: 正常数量
:param warn_count: 警告数量
:param fail_count: 失败数量
:return: 摘要文本
"""
posture = '存在风险' if fail_count else '需要关注' if warn_count else '运行稳定'
return (
f'当前态势:{posture}。共 {total_count} 个面板,正常 {ok_count} 个,'
f'警告 {warn_count} 个,失败 {fail_count} 个。'
)
@staticmethod
def build_detail_summary_text(total_count: int, ok_count: int, warn_count: int, fail_count: int) -> str:
"""
构建详情页摘要文本。
:param total_count: 分区总数
:param ok_count: 正常数量
:param warn_count: 警告数量
:param fail_count: 失败数量
:return: 摘要文本
"""
return (
f'当前页共 {total_count} 个分区,正常 {ok_count} 个,警告 {warn_count} 个,失败 {fail_count} 个。'
'左侧上下切页,左右切换当前焦点区域。'
)
@staticmethod
def build_browser_summary_text(
total_count: int,
ok_count: int,
warn_count: int,
fail_count: int,
action_hint: str,
) -> str:
"""
构建浏览页摘要文本。
:param total_count: 记录总数
:param ok_count: 正常数量
:param warn_count: 警告数量
:param fail_count: 失败数量
:param action_hint: 动作提示文案
:return: 摘要文本
"""
return (
f'当前页共 {total_count} 条记录,正常 {ok_count} 条,警告 {warn_count} 条,失败 {fail_count} 条。'
f'切换记录后,右侧会联动展示概览与分区详情。{action_hint}'
)
@@ -0,0 +1,581 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from cli.utils import SHELL_TEXT_FORMATTER
@dataclass(frozen=True)
class DiagnosticPagePolicy:
"""
TUI 页面诊断策略定义。
:param focus_terms: 页面聚焦词
:param subtitle_builder: 页面摘要构建函数
"""
focus_terms: tuple[str, ...]
subtitle_builder: Callable[..., str]
@dataclass(frozen=True)
class DiagnosticPagePolicyRegistry:
"""
TUI 页面诊断策略注册表。
:param policies: 页面诊断策略映射
"""
policies: dict[str, DiagnosticPagePolicy]
def get(self, page_key: str) -> DiagnosticPagePolicy:
"""
读取指定页面诊断策略。
:param page_key: 页面标识
:return: 页面诊断策略
:raises KeyError: 页面未注册时抛出异常
"""
return self.policies[page_key]
class TuiDiagnosticService:
"""
统一封装 TUI 页面诊断提示与摘要拼装逻辑。
该服务通过页面级诊断策略注册表管理各页面的聚焦词和 subtitle 规则,
供 adapter、dashboard 等对象复用。
:param page_policy_registry: 页面诊断策略注册表
"""
def __init__(self, page_policy_registry: DiagnosticPagePolicyRegistry) -> None:
"""
初始化诊断服务。
:param page_policy_registry: 页面诊断策略注册表
:return: None
"""
self.page_policy_registry = page_policy_registry
@staticmethod
def build_focus_hint(terms: tuple[str, ...]) -> str:
"""
将诊断聚焦词渲染为统一提示文本。
:param terms: 聚焦词元组
:return: 提示文本
"""
return f'聚焦:{" / ".join(terms)}'
def build_page_focus_hint(self, page_key: str) -> str:
"""
构建指定页面统一聚焦词。
:param page_key: 页面标识
:return: 聚焦提示
"""
return self.build_focus_hint(self.page_policy_registry.get(page_key).focus_terms)
def build_database_focus_hint(self) -> str:
"""
构建数据库页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('database')
def build_ops_focus_hint(self) -> str:
"""
构建运维页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('ops')
def build_cache_focus_hint(self) -> str:
"""
构建缓存页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('cache')
def build_app_focus_hint(self) -> str:
"""
构建应用页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('app')
def build_jobs_focus_hint(self) -> str:
"""
构建任务页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('jobs')
def build_configs_focus_hint(self) -> str:
"""
构建参数配置页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('configs')
def build_gen_focus_hint(self) -> str:
"""
构建代码生成页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('gen')
def build_crypto_focus_hint(self) -> str:
"""
构建加密页统一聚焦词。
:return: 聚焦提示
"""
return self.build_page_focus_hint('crypto')
@staticmethod
def _build_app_subtitle(
env_payload: dict[str, Any] | None,
config_payload: dict[str, Any] | None,
doctor_payload: dict[str, Any] | None,
routes_payload: dict[str, Any] | None,
completion_payload: dict[str, Any] | None,
*,
focus_hint: str,
) -> str:
"""
构建应用页诊断摘要。
:param env_payload: `app env` 结果
:param config_payload: `app config` 结果
:param doctor_payload: `app doctor` 结果
:param routes_payload: `app routes` 结果
:param completion_payload: `completion doctor` 结果
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
env_ok = bool(isinstance(env_payload, dict) and env_payload.get('ok', False))
config_ok = bool(isinstance(config_payload, dict) and config_payload.get('ok', False))
doctor_ok = bool(isinstance(doctor_payload, dict) and doctor_payload.get('ok', False))
routes_ok = bool(isinstance(routes_payload, dict) and routes_payload.get('ok', False))
completion_ok = bool(isinstance(completion_payload, dict) and completion_payload.get('ok', False))
route_count = routes_payload.get('count', 0) if isinstance(routes_payload, dict) else 0
if not env_ok or not config_ok:
return f'{focus_hint} | 应用基础信息读取异常,优先确认环境映射与配置摘要'
if not doctor_ok:
return f'{focus_hint} | 启动前检查异常,优先确认数据库、Redis 与加密组件状态'
if not completion_ok:
return f'{focus_hint} | 补全诊断读取异常,优先确认项目目录与 shell 补全配置'
if not routes_ok:
return f'{focus_hint} | 路由摘要读取异常,优先确认应用初始化与路由注册状态'
return (
f'{focus_hint} | 当前已加载 {route_count} 条注册路由,'
'可继续查看环境映射、配置摘要、启动前检查、补全诊断与路由状态'
)
@staticmethod
def _build_database_subtitle(
revision_payload: dict[str, Any] | None,
check_payload: dict[str, Any] | None,
heads_payload: dict[str, Any] | None,
*,
focus_hint: str,
) -> str:
"""
构建数据库页诊断摘要。
:param revision_payload: `db current` 结果
:param check_payload: `db check` 结果
:param heads_payload: `db heads` 结果
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
revision = revision_payload.get('currentRevision', '-') if isinstance(revision_payload, dict) else '-'
revision_text = SHELL_TEXT_FORMATTER.truncate_text(str(revision or '-'), 48)
revision_ok = bool(isinstance(revision_payload, dict) and revision_payload.get('ok', False))
check_ok = bool(isinstance(check_payload, dict) and check_payload.get('ok', False))
heads_ok = bool(isinstance(heads_payload, dict) and heads_payload.get('ok', False))
heads_items = heads_payload.get('items') if isinstance(heads_payload, dict) else None
heads_count = len(heads_items) if isinstance(heads_items, list) else 0
if not revision_ok or not check_ok:
return f'{focus_hint} | 数据库异常,优先确认迁移版本、连接状态与 heads 信息'
if not heads_ok or heads_count != 1:
return f'{focus_hint} | 数据库存在迁移分叉风险,优先确认 heads 和历史版本'
return f'{focus_hint} | 数据库基线正常,当前 revision {revision_text},可继续查看连接、heads 和历史版本'
@staticmethod
def _build_ops_subtitle(
health_payload: dict[str, Any] | None,
deps_payload: dict[str, Any] | None,
server_payload: dict[str, Any] | None,
*,
focus_hint: str,
) -> str:
"""
构建运维页诊断摘要。
:param health_payload: `ops health` 结果
:param deps_payload: `ops deps` 结果
:param server_payload: `ops server-info` 结果
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
health_ok = bool(isinstance(health_payload, dict) and health_payload.get('ok', False))
deps_ok = bool(isinstance(deps_payload, dict) and deps_payload.get('ok', False))
server_ok = bool(isinstance(server_payload, dict) and server_payload.get('ok', False))
if not health_ok:
return f'{focus_hint} | 运维探活存在异常,优先核对数据库/Redis 连通性与依赖版本'
if not deps_ok:
return f'{focus_hint} | 运维依赖存在异常,优先核对缺失依赖与版本兼容性'
if not server_ok:
return f'{focus_hint} | 服务器信息采集异常,优先重新检查主机资源与运行环境'
return f'{focus_hint} | 基础探活和依赖状态正常,可继续查看服务器资源与磁盘样本'
@staticmethod
def _build_cache_subtitle(
stats_payload: dict[str, Any] | None,
matched_count: int,
*,
focus_hint: str,
) -> str:
"""
构建缓存页诊断摘要。
:param stats_payload: `cache stats` 结果
:param matched_count: 当前筛选后缓存名数量
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
db_size = stats_payload.get('dbSize', '-') if isinstance(stats_payload, dict) else '-'
client_count = '-'
if isinstance(stats_payload, dict) and isinstance(stats_payload.get('info'), dict):
client_count = stats_payload['info'].get('connected_clients', '-') # type: ignore[index]
return (
f'{focus_hint} | 缓存基线正常,当前已加载 {matched_count} 个缓存名,'
f'Redis 键数 {db_size},连接数 {client_count},可继续查看键列表、键值样本和 TTL'
)
@staticmethod
def _build_jobs_subtitle(
filter_label: str,
matched_count: int,
failed_job_names: set[str],
paused_count: int,
*,
focus_hint: str,
) -> str:
"""
构建任务页诊断摘要。
:param filter_label: 当前筛选标签
:param matched_count: 当前匹配任务数
:param failed_job_names: 最近失败任务集合
:param paused_count: 暂停任务数量
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
if failed_job_names:
return (
f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 条任务,'
f'失败任务 {len(failed_job_names)} 个,暂停任务 {paused_count} 个'
)
return (
f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 条任务,'
f'当前没有失败任务,暂停任务 {paused_count} 个'
)
@staticmethod
def _build_configs_subtitle(
filter_label: str,
matched_count: int,
mismatch_count: int,
drift_count: int,
*,
focus_hint: str,
) -> str:
"""
构建参数配置页诊断摘要。
:param filter_label: 当前筛选标签
:param matched_count: 当前匹配配置数
:param mismatch_count: 值不一致数量
:param drift_count: 缓存漂移数量
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
return (
f'{focus_hint} | 当前筛选:{filter_label},已匹配 {matched_count} 项配置,'
f'值不一致 {mismatch_count} 项,缓存漂移 {drift_count} 项'
)
@staticmethod
def _build_gen_subtitle(
matched_count: int,
importable_count: int,
*,
focus_hint: str,
) -> str:
"""
构建代码生成页诊断摘要。
:param matched_count: 当前匹配业务表数
:param importable_count: 可导入物理表数
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
return (
f'{focus_hint} | 当前已匹配 {matched_count} 张业务表,'
f'可导入物理表 {importable_count} 张,可继续查看表定义、预检查和代码预览'
)
@staticmethod
def _build_crypto_subtitle(
validate_payload: dict[str, Any] | None,
public_payload: dict[str, Any] | None,
*,
focus_hint: str,
) -> str:
"""
构建加密页诊断摘要。
:param validate_payload: `crypto validate` 结果
:param public_payload: `crypto export-public` 结果
:param focus_hint: 聚焦提示
:return: 页面摘要
"""
validate_ok = bool(isinstance(validate_payload, dict) and validate_payload.get('ok', False))
public_ok = bool(isinstance(public_payload, dict) and public_payload.get('ok', False))
public_key_payload = public_payload.get('publicKey') if isinstance(public_payload, dict) else None
current_kid = public_key_payload.get('kid', '-') if isinstance(public_key_payload, dict) else '-'
supported_kids = public_key_payload.get('supportedKids') if isinstance(public_key_payload, dict) else None
supported_count = len(supported_kids) if isinstance(supported_kids, list) else 0
if not validate_ok:
return f'{focus_hint} | 运行校验失败,优先确认传输加密配置与环境变量'
if not public_ok or not isinstance(public_key_payload, dict):
return f'{focus_hint} | 公钥导出结果异常,优先确认当前 KID 与公钥内容'
return (
f'{focus_hint} | 当前 KID {current_kid},兼容版本 {supported_count} 个,'
'可继续查看公钥身份、兼容版本与轮换预演入口'
)
def build_app_diagnostic_subtitle(
self,
env_payload: dict[str, Any] | None,
config_payload: dict[str, Any] | None,
doctor_payload: dict[str, Any] | None,
routes_payload: dict[str, Any] | None,
completion_payload: dict[str, Any] | None,
) -> str:
"""
构建应用页统一诊断摘要。
:param env_payload: `app env` 结果
:param config_payload: `app config` 结果
:param doctor_payload: `app doctor` 结果
:param routes_payload: `app routes` 结果
:param completion_payload: `completion doctor` 结果
:return: 页面摘要
"""
return self.page_policy_registry.get('app').subtitle_builder(
env_payload,
config_payload,
doctor_payload,
routes_payload,
completion_payload,
focus_hint=self.build_app_focus_hint(),
)
def build_database_diagnostic_subtitle(
self,
revision_payload: dict[str, Any] | None,
check_payload: dict[str, Any] | None,
heads_payload: dict[str, Any] | None,
) -> str:
"""
构建数据库页统一诊断摘要。
:param revision_payload: `db current` 结果
:param check_payload: `db check` 结果
:param heads_payload: `db heads` 结果
:return: 页面摘要
"""
return self.page_policy_registry.get('database').subtitle_builder(
revision_payload,
check_payload,
heads_payload,
focus_hint=self.build_database_focus_hint(),
)
def build_ops_diagnostic_subtitle(
self,
health_payload: dict[str, Any] | None,
deps_payload: dict[str, Any] | None,
server_payload: dict[str, Any] | None,
) -> str:
"""
构建运维页统一诊断摘要。
:param health_payload: `ops health` 结果
:param deps_payload: `ops deps` 结果
:param server_payload: `ops server-info` 结果
:return: 页面摘要
"""
return self.page_policy_registry.get('ops').subtitle_builder(
health_payload,
deps_payload,
server_payload,
focus_hint=self.build_ops_focus_hint(),
)
def build_cache_diagnostic_subtitle(
self,
stats_payload: dict[str, Any] | None,
matched_count: int,
) -> str:
"""
构建缓存页统一诊断摘要。
:param stats_payload: `cache stats` 结果
:param matched_count: 当前筛选后缓存名数量
:return: 页面摘要
"""
return self.page_policy_registry.get('cache').subtitle_builder(
stats_payload,
matched_count,
focus_hint=self.build_cache_focus_hint(),
)
def build_jobs_diagnostic_subtitle(
self,
filter_label: str,
matched_count: int,
failed_job_names: set[str],
paused_count: int,
) -> str:
"""
构建任务页统一诊断摘要。
:param filter_label: 当前筛选标签
:param matched_count: 当前匹配任务数
:param failed_job_names: 最近失败任务集合
:param paused_count: 暂停任务数量
:return: 页面摘要
"""
return self.page_policy_registry.get('jobs').subtitle_builder(
filter_label,
matched_count,
failed_job_names,
paused_count,
focus_hint=self.build_jobs_focus_hint(),
)
def build_configs_diagnostic_subtitle(
self,
filter_label: str,
matched_count: int,
mismatch_count: int,
drift_count: int,
) -> str:
"""
构建参数配置页统一诊断摘要。
:param filter_label: 当前筛选标签
:param matched_count: 当前匹配配置数
:param mismatch_count: 值不一致数量
:param drift_count: 缓存漂移数量
:return: 页面摘要
"""
return self.page_policy_registry.get('configs').subtitle_builder(
filter_label,
matched_count,
mismatch_count,
drift_count,
focus_hint=self.build_configs_focus_hint(),
)
def build_gen_diagnostic_subtitle(
self,
matched_count: int,
importable_count: int,
) -> str:
"""
构建代码生成页统一诊断摘要。
:param matched_count: 当前匹配业务表数
:param importable_count: 可导入物理表数
:return: 页面摘要
"""
return self.page_policy_registry.get('gen').subtitle_builder(
matched_count,
importable_count,
focus_hint=self.build_gen_focus_hint(),
)
def build_crypto_diagnostic_subtitle(
self,
validate_payload: dict[str, Any] | None,
public_payload: dict[str, Any] | None,
) -> str:
"""
构建加密页统一诊断摘要。
:param validate_payload: `crypto validate` 结果
:param public_payload: `crypto export-public` 结果
:return: 页面摘要
"""
return self.page_policy_registry.get('crypto').subtitle_builder(
validate_payload,
public_payload,
focus_hint=self.build_crypto_focus_hint(),
)
TUI_DIAGNOSTIC_PAGE_POLICY_REGISTRY = DiagnosticPagePolicyRegistry(
policies={
'database': DiagnosticPagePolicy(
focus_terms=('迁移版本', '连接状态', 'Heads'),
subtitle_builder=TuiDiagnosticService._build_database_subtitle,
),
'ops': DiagnosticPagePolicy(
focus_terms=('数据库连通性', 'Redis 连通性', '依赖版本'),
subtitle_builder=TuiDiagnosticService._build_ops_subtitle,
),
'cache': DiagnosticPagePolicy(
focus_terms=('Redis 键数', '连接数', '缓存名前缀'),
subtitle_builder=TuiDiagnosticService._build_cache_subtitle,
),
'app': DiagnosticPagePolicy(
focus_terms=('环境映射', '配置摘要', '启动前检查'),
subtitle_builder=TuiDiagnosticService._build_app_subtitle,
),
'jobs': DiagnosticPagePolicy(
focus_terms=('失败聚合', '暂停任务', '执行轨迹'),
subtitle_builder=TuiDiagnosticService._build_jobs_subtitle,
),
'configs': DiagnosticPagePolicy(
focus_terms=('高风险配置', '值不一致', '缓存漂移'),
subtitle_builder=TuiDiagnosticService._build_configs_subtitle,
),
'gen': DiagnosticPagePolicy(
focus_terms=('生成前校验', '同步预检查', '代码预览'),
subtitle_builder=TuiDiagnosticService._build_gen_subtitle,
),
'crypto': DiagnosticPagePolicy(
focus_terms=('运行校验', '公钥身份', '兼容版本'),
subtitle_builder=TuiDiagnosticService._build_crypto_subtitle,
),
}
)
TUI_DIAGNOSTIC_SERVICE = TuiDiagnosticService(TUI_DIAGNOSTIC_PAGE_POLICY_REGISTRY)
@@ -0,0 +1,177 @@
from dataclasses import dataclass, field
@dataclass(frozen=True)
class TuiKeymapRegistry:
"""
TUI 键位注册表。
该对象集中维护页面切换快捷键和派生提示文案,避免模块级
常量散落并在后续扩展时继续增长。
:param navigation_shortcuts: 页面导航快捷键映射
"""
navigation_shortcuts: dict[str, str] = field(default_factory=dict)
@staticmethod
def normalize_view_key(view_key: str) -> str:
"""
规范化页面标识。
:param view_key: 原始页面标识
:return: 规范化后的页面标识
"""
return str(view_key).strip().lower()
def get_navigation_shortcut(self, view_key: str) -> str:
"""
获取指定页面的导航快捷键。
:param view_key: 页面标识
:return: 快捷键文本
"""
normalized_view_key = self.normalize_view_key(view_key)
return self.navigation_shortcuts.get(normalized_view_key, '')
@property
def navigation_shortcut_hint(self) -> str:
"""
构建页面导航快捷键总提示。
:return: 快捷键提示
"""
ordered_keys = ['dashboard', 'app', 'ops', 'database', 'cache', 'jobs', 'gen', 'configs', 'crypto']
shortcuts = [
self.get_navigation_shortcut(view_key).upper()
for view_key in ordered_keys
if self.get_navigation_shortcut(view_key)
]
return f'[{" / ".join(shortcuts).replace(" / ", "/")}]'
@property
def sidebar_shortcut_hint(self) -> str:
"""
获取侧栏快捷键提示。
:return: 提示文本
"""
return '[S] 侧栏'
@property
def refresh_shortcut_hint(self) -> str:
"""
获取刷新快捷键提示。
:return: 提示文本
"""
return '[R] 刷新'
@property
def quit_shortcut_hint(self) -> str:
"""
获取退出快捷键提示。
:return: 提示文本
"""
return '[Q] 退出'
@property
def focus_shortcut_hint(self) -> str:
"""
获取焦点切换快捷键提示。
:return: 提示文本
"""
return '[←/→] 切换焦点'
@property
def region_switch_shortcut_hint(self) -> str:
"""
获取区域切换快捷键提示。
:return: 提示文本
"""
return '[←/→] 切换区域'
@property
def scroll_shortcut_hint(self) -> str:
"""
获取滚动快捷键提示。
:return: 提示文本
"""
return '[J/K] 滚动'
@property
def page_scroll_shortcut_hint(self) -> str:
"""
获取翻页快捷键提示。
:return: 提示文本
"""
return '[PgUp/PgDn] 翻页'
@property
def home_end_shortcut_hint(self) -> str:
"""
获取首尾跳转快捷键提示。
:return: 提示文本
"""
return '[Home/End] 首尾'
@property
def browser_interaction_hint(self) -> str:
"""
构建浏览页交互提示。
:return: 提示文本
"""
return (
f'浏览键:{self.focus_shortcut_hint} {self.scroll_shortcut_hint} '
f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint}'
)
@property
def global_shortcut_hint(self) -> str:
"""
构建全局快捷键提示。
:return: 提示文本
"""
return (
f'页面 {self.navigation_shortcut_hint} {self.sidebar_shortcut_hint} '
f'{self.focus_shortcut_hint} {self.scroll_shortcut_hint} '
f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint} '
f'{self.refresh_shortcut_hint} {self.quit_shortcut_hint}'
)
@property
def hero_shortcut_hint(self) -> str:
"""
构建首页英雄区快捷键提示。
:return: 提示文本
"""
return (
f'页面 {self.navigation_shortcut_hint} {self.sidebar_shortcut_hint} '
f'{self.region_switch_shortcut_hint} {self.scroll_shortcut_hint} '
f'{self.page_scroll_shortcut_hint} {self.home_end_shortcut_hint} '
f'{self.refresh_shortcut_hint} {self.quit_shortcut_hint}'
)
TUI_KEYMAP_REGISTRY = TuiKeymapRegistry(
navigation_shortcuts={
'dashboard': 'd',
'app': 'a',
'ops': 'o',
'database': 'b',
'cache': 'c',
'jobs': 't',
'gen': 'g',
'configs': 'p',
'crypto': 'e',
}
)
@@ -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()
+375
View File
@@ -0,0 +1,375 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
CompletionProviderCallable = Callable[[object, list[str] | None, str], list[str]]
@dataclass(frozen=True)
class PageFilterOption:
"""
浏览页筛选项定义。
:param key: 筛选键
:param label: 展示名称
:param shortcut: 快捷键
"""
key: str
label: str
shortcut: str
@dataclass(frozen=True)
class PageSearchContext:
"""
浏览页搜索上下文定义。
:param placeholder: 搜索输入提示
:param query: 当前搜索词
:param suggestions: 候选建议列表
"""
placeholder: str
query: str = ''
suggestions: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class SearchSuggestionProviderSpec:
"""
页面搜索建议提供者定义。
:param placeholder: 搜索输入提示
:param suggestion_provider: 候选建议提供函数
"""
placeholder: str
suggestion_provider: Callable[[str], list[str]] | None = None
JOB_FILTER_OPTIONS: tuple[PageFilterOption, ...] = (
PageFilterOption('all', '全部', '1'),
PageFilterOption('failed', '失败', '2'),
PageFilterOption('paused', '暂停', '3'),
PageFilterOption('ok', '正常', '4'),
)
CONFIG_FILTER_OPTIONS: tuple[PageFilterOption, ...] = (
PageFilterOption('all', '全部', '1'),
PageFilterOption('risky', '高风险', '2'),
PageFilterOption('mismatch', '值不一致', '3'),
PageFilterOption('cache-drift', '缓存异常', '4'),
)
class TuiSearchSuggestionProviderRegistry:
"""
TUI 搜索建议提供者注册表。
该对象负责管理页面搜索提示文案及其候选建议提供函数,避免搜索服务
继续通过字符串反射隐式定位 completion provider。
:param providers: 页面搜索提供者映射
"""
def __init__(self, providers: dict[str, SearchSuggestionProviderSpec]) -> None:
"""
初始化搜索建议提供者注册表。
:param providers: 页面搜索提供者映射
:return: None
"""
self.providers = {self.normalize_view_key(key): value for key, value in providers.items()}
@staticmethod
def normalize_view_key(view_key: str) -> str:
"""
规范化页面视图标识。
:param view_key: 原始页面视图标识
:return: 规范化后的页面视图标识
"""
return str(view_key or '').strip().lower()
def get_provider(self, view_key: str) -> SearchSuggestionProviderSpec | None:
"""
获取指定页面的搜索建议提供者。
:param view_key: 页面视图标识
:return: 搜索建议提供者定义
"""
return self.providers.get(self.normalize_view_key(view_key))
class CompletionSuggestionProviderFactory:
"""
基于 completion 注册表构建 TUI 搜索建议提供函数。
:param completion_provider_gateway: completion 提供器对外网关
"""
def __init__(self, completion_provider_gateway: CompletionProviderGateway) -> None:
"""
初始化 completion 搜索建议工厂。
:param completion_provider_gateway: completion 提供器对外网关
:return: None
"""
self.completion_provider_gateway = completion_provider_gateway
def build(self, provider: CompletionProviderCallable) -> Callable[[str], list[str]]:
"""
根据显式 completion provider 构建建议提供函数。
:param provider: completion provider 可调用对象
:return: 搜索建议提供函数
"""
def provide(query: str) -> list[str]:
"""
调用 completion provider 生成候选建议。
:param query: 当前搜索词
:return: 候选建议列表
"""
candidates = provider(None, [], str(query or '').strip())
return [str(candidate).strip() for candidate in candidates if str(candidate).strip()]
return provide
def build_default_search_provider_registry(
completion_provider_gateway: CompletionProviderGateway,
) -> TuiSearchSuggestionProviderRegistry:
"""
构建默认 TUI 搜索建议提供者注册表。
:param completion_provider_gateway: completion 提供器对外网关
:return: 搜索建议提供者注册表
"""
provider_factory = CompletionSuggestionProviderFactory(completion_provider_gateway)
return TuiSearchSuggestionProviderRegistry(
providers={
'jobs': SearchSuggestionProviderSpec(
'按任务名搜索',
provider_factory.build(completion_provider_gateway.complete_job_names),
),
'configs': SearchSuggestionProviderSpec(
'按配置键搜索',
provider_factory.build(completion_provider_gateway.complete_config_keys),
),
'cache': SearchSuggestionProviderSpec(
'按缓存名搜索',
provider_factory.build(completion_provider_gateway.complete_cache_names),
),
'gen': SearchSuggestionProviderSpec(
'按业务表名搜索',
provider_factory.build(completion_provider_gateway.complete_gen_table_names),
),
'database': SearchSuggestionProviderSpec(
'按 revision 搜索',
provider_factory.build(completion_provider_gateway.complete_alembic_revisions),
),
'app': SearchSuggestionProviderSpec('按分区或内容搜索'),
'ops': SearchSuggestionProviderSpec('按分区或内容搜索'),
'crypto': SearchSuggestionProviderSpec('按分区或内容搜索'),
}
)
class TuiSearchHighlighter:
"""
TUI 搜索高亮器。
该对象负责对终端文本做轻量关键字高亮。
"""
@staticmethod
def highlight(text: str, query: str, *, left_tag: str = '【', right_tag: str = '】') -> str:
"""
对给定文本中的搜索词做轻量高亮。
:param text: 原始文本
:param query: 搜索词
:param left_tag: 左高亮标记
:param right_tag: 右高亮标记
:return: 高亮后的文本
"""
raw_text = str(text or '')
normalized_query = str(query or '').strip()
if not raw_text or not normalized_query:
return raw_text
lower_text = raw_text.lower()
lower_query = normalized_query.lower()
result: list[str] = []
cursor = 0
query_length = len(normalized_query)
while True:
index = lower_text.find(lower_query, cursor)
if index < 0:
result.append(raw_text[cursor:])
break
result.append(raw_text[cursor:index])
result.append(f'{left_tag}{raw_text[index : index + query_length]}{right_tag}')
cursor = index + query_length
return ''.join(result)
class TuiSearchService:
"""
TUI 搜索服务。
该对象负责筛选项解析、筛选条文案构建、搜索上下文装配和详情分区过滤。
"""
def __init__(self, provider_registry: TuiSearchSuggestionProviderRegistry) -> None:
"""
初始化 TUI 搜索服务。
:param provider_registry: 页面搜索建议提供者注册表
:return: None
"""
self.provider_registry = provider_registry
@staticmethod
def resolve_filter_option(
options: list[PageFilterOption] | tuple[PageFilterOption, ...],
filter_key: str | None,
) -> PageFilterOption | None:
"""
从筛选项列表中解析指定键对应的筛选定义。
:param options: 筛选项列表
:param filter_key: 筛选键
:return: 匹配到的筛选项
"""
normalized_key = str(filter_key or '').strip().lower()
if not normalized_key:
return None
for option in options:
if option.key == normalized_key:
return option
return None
def build_filter_bar_text(
self,
options: list[PageFilterOption] | tuple[PageFilterOption, ...],
active_filter_key: str | None,
*,
search_query: str | None = None,
search_placeholder: str | None = None,
search_suggestions: list[str] | None = None,
) -> str:
"""
构建浏览页顶部筛选条文本。
:param options: 筛选项列表
:param active_filter_key: 当前激活筛选键
:param search_query: 当前搜索词
:param search_placeholder: 搜索提示文本
:param search_suggestions: 搜索候选建议
:return: 筛选条文本
"""
if not options and not search_placeholder:
return ''
lines: list[str] = []
normalized_active = str(active_filter_key or '').strip().lower()
if options:
rendered_items = [
f'[{option.shortcut}] {option.label}{" *" if option.key == normalized_active else ""}'
for option in options
]
active_option = self.resolve_filter_option(options, normalized_active)
active_label = active_option.label if active_option is not None else '未指定'
lines.extend(
[
f'筛选器 · {" ".join(rendered_items)}',
f'当前筛选 · {active_label}',
]
)
if search_placeholder:
normalized_query = str(search_query or '').strip()
lines.append(f'搜索器 · [/] {search_placeholder}')
lines.append(f'当前搜索 · {normalized_query or "未指定"} | [Backspace] 清空')
if search_suggestions:
lines.append(f'候选建议 · {" ".join(search_suggestions[:4])}')
return '\n'.join(lines)
def resolve_search_context(
self,
view_key: str,
query: str = '',
*,
default_suggestions: list[str] | None = None,
) -> PageSearchContext | None:
"""
根据页面视图解析搜索上下文,并复用 completion provider 生成候选建议。
:param view_key: 当前页面视图标识
:param query: 当前搜索词
:param default_suggestions: 缺省候选建议
:return: 搜索上下文
"""
normalized_view = str(view_key).strip().lower()
provider_spec = self.provider_registry.get_provider(normalized_view)
if provider_spec is None:
return None
suggestions = self.resolve_search_suggestions(provider_spec, query)
if not suggestions and default_suggestions:
normalized_query = str(query or '').strip().lower()
suggestions = [
suggestion
for suggestion in default_suggestions
if suggestion and (not normalized_query or normalized_query in suggestion.lower())
]
return PageSearchContext(
placeholder=provider_spec.placeholder,
query=str(query or '').strip(),
suggestions=suggestions[:6],
)
@staticmethod
def resolve_search_suggestions(provider_spec: SearchSuggestionProviderSpec, query: str) -> list[str]:
"""
调用搜索建议提供函数生成当前搜索词的候选建议。
:param provider_spec: 搜索建议提供者定义
:param query: 当前搜索词
:return: 候选建议列表
"""
normalized_query = str(query or '').strip()
if not normalized_query or not callable(provider_spec.suggestion_provider):
return []
try:
candidates = provider_spec.suggestion_provider(normalized_query)
except Exception:
return []
return [str(candidate).strip() for candidate in candidates if str(candidate).strip()]
@staticmethod
def filter_detail_sections(sections: list[object], query: str) -> list[object]:
"""
按搜索词过滤详情页分区列表。
:param sections: 原始分区列表
:param query: 当前搜索词
:return: 过滤后的分区列表
"""
normalized_query = str(query or '').strip().lower()
if not normalized_query:
return sections
filtered_sections: list[object] = []
for section in sections:
title = str(getattr(section, 'title', '') or '').strip().lower()
lines = getattr(section, 'lines', [])
joined_lines = ' '.join(str(line).strip().lower() for line in lines if str(line).strip())
if normalized_query in title or normalized_query in joined_lines:
filtered_sections.append(section)
return filtered_sections
TUI_SEARCH_HIGHLIGHTER = TuiSearchHighlighter()
TUI_SEARCH_PROVIDER_REGISTRY = build_default_search_provider_registry(COMPLETION_PROVIDER_GATEWAY)
TUI_SEARCH_SERVICE = TuiSearchService(TUI_SEARCH_PROVIDER_REGISTRY)
@@ -0,0 +1,27 @@
from cli.tui.widgets.status_panel import MetricPanel, SignalRail, StatusPanel
from cli.tui.widgets.workspace import (
NavigationItem,
RecordDetailView,
RecordNavigator,
RecordSummaryView,
SectionDetailView,
SectionNavigator,
WorkspaceHeader,
WorkspaceHero,
WorkspaceSidebar,
)
__all__ = [
'MetricPanel',
'NavigationItem',
'RecordDetailView',
'RecordNavigator',
'RecordSummaryView',
'SectionDetailView',
'SectionNavigator',
'SignalRail',
'StatusPanel',
'WorkspaceHeader',
'WorkspaceHero',
'WorkspaceSidebar',
]
@@ -0,0 +1,323 @@
from textual.widgets import Static
from cli.tui.copy import TUI_COPY
class StatusPanelRenderingSupport:
"""
状态面板渲染支持对象。
该对象负责状态类名解析、结构化正文渲染和信号带脉冲文本构建,
供状态面板、指标卡与信号带组件复用。
"""
PULSE_FRAMES = ['·', '•', '◉', '•']
@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', 'down'}:
return 'is-fail'
if normalized_status in {'warn', 'warning', 'degraded'}:
return 'is-warn'
return 'is-info'
def render_structured_body(self, body: str) -> str:
"""
将结构化正文文本渲染为更有层次的终端块内容。
支持以下约定:
- `## ` 开头:分组标题
- `> ` 开头:说明或子项
- 空字符串:保留空行
:param body: 原始正文文本
:return: 渲染后的正文文本
"""
rendered_lines: list[str] = []
for raw_line in body.splitlines():
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, 28)))
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()
return '\n'.join(rendered_lines) if rendered_lines else f'- {TUI_COPY.build_status_panel_empty_text()}'
def build_signal_rail_text(self, lines: list[str], pulse_index: int) -> str:
"""
构建信号带渲染文本。
:param lines: 信号文本行
:param pulse_index: 当前脉冲帧索引
:return: 渲染文本
"""
pulse = self.PULSE_FRAMES[pulse_index % len(self.PULSE_FRAMES)]
body = '\n'.join(line for line in lines if line.strip())
return TUI_COPY.build_signal_rail_text(pulse, body or TUI_COPY.build_signal_rail_empty_text())
STATUS_PANEL_RENDERING = StatusPanelRenderingSupport()
class StatusPanel(Static):
"""
TUI 状态面板组件。
:param title: 面板标题
:param status: 面板状态
:param body: 面板正文
"""
DEFAULT_CSS = """
StatusPanel {
border: double #245874;
padding: 1 2;
margin: 0;
height: 17;
min-height: 17;
background: #08131f;
color: #def8ff;
}
StatusPanel.is-ok {
border: double #22c983;
background: #081b17;
}
StatusPanel.is-fail {
border: double #ff6b7a;
background: #241118;
}
StatusPanel.is-warn {
border: double #f2b84b;
background: #261d0f;
}
StatusPanel.is-info {
border: double #38d8ff;
background: #081825;
}
StatusPanel:focus {
border: heavy #6df7ff;
background: #10364b;
}
"""
def __init__(
self,
title: str,
status: str,
body: str,
rendering: StatusPanelRenderingSupport | None = None,
) -> None:
"""
初始化状态面板。
:param title: 面板标题
:param status: 面板状态
:param body: 面板正文
:param rendering: 状态面板渲染支持对象
:return: None
"""
self.title = title
self.status = status
self.body = body
self.rendering = rendering or STATUS_PANEL_RENDERING
super().__init__(self._build_render_text(), markup=False)
self.can_focus = True
self.add_class(self.rendering.resolve_status_class(self.status))
def _build_render_text(self) -> str:
"""
构建面板渲染文本。
:return: 渲染文本
"""
rendered_body = self.rendering.render_structured_body(self.body)
normalized_status = TUI_COPY.render_status_label(self.status)
return TUI_COPY.build_status_panel_text(
self.title,
TUI_COPY.render_status_code(self.status),
normalized_status,
rendered_body,
)
class MetricPanel(Static):
"""
TUI 首页驾驶舱指标卡组件。
:param title: 指标标题
:param value: 指标主值
:param status: 指标状态
:param hint: 指标补充说明
"""
DEFAULT_CSS = """
MetricPanel {
border: double #245874;
padding: 1 2;
height: 12;
min-height: 12;
background: #08131f;
color: #effcff;
}
MetricPanel.is-primary {
border: heavy #6df7ff;
height: 12;
min-height: 12;
background: #0a1725;
text-style: bold;
}
MetricPanel.is-ok {
border: double #22c983;
background: #081b17;
}
MetricPanel.is-fail {
border: double #ff6b7a;
background: #241118;
}
MetricPanel.is-warn {
border: double #f2b84b;
background: #261d0f;
}
MetricPanel.is-info {
border: double #38d8ff;
background: #081825;
}
"""
def __init__(
self,
title: str,
value: str,
status: str,
hint: str,
*,
accent: bool = False,
rendering: StatusPanelRenderingSupport | None = None,
) -> None:
"""
初始化指标卡。
:param title: 指标标题
:param value: 指标主值
:param status: 指标状态
:param hint: 指标补充说明
:param accent: 是否作为主指标高亮展示
:param rendering: 状态面板渲染支持对象
:return: None
"""
self.title = title
self.value = value
self.status = status
self.hint = hint
self.rendering = rendering or STATUS_PANEL_RENDERING
super().__init__(self._build_render_text(), markup=False)
self.add_class(self.rendering.resolve_status_class(self.status))
if accent:
self.add_class('is-primary')
def _build_render_text(self) -> str:
"""
构建指标卡渲染文本。
:return: 渲染文本
"""
normalized_status = TUI_COPY.render_status_label(self.status)
return TUI_COPY.build_metric_panel_text(
self.title,
self.value,
TUI_COPY.render_status_code(self.status),
normalized_status,
self.hint,
)
class SignalRail(Static):
"""
首页信号带组件,用于展示简洁的系统态势条目。
:param lines: 信号文本行
"""
DEFAULT_CSS = """
SignalRail {
border: double #1db9d1;
padding: 1 2;
margin-top: 1;
margin-bottom: 1;
min-height: 6;
background: #06111b;
color: #e9fdff;
}
"""
def __init__(self, lines: list[str], rendering: StatusPanelRenderingSupport | None = None) -> None:
"""
初始化首页信号带。
:param lines: 信号文本行
:param rendering: 状态面板渲染支持对象
:return: None
"""
self.lines = lines
self._pulse_index = 0
self.rendering = rendering or STATUS_PANEL_RENDERING
super().__init__(self._build_render_text(), markup=False)
def on_mount(self) -> None:
"""
挂载后启动轻量脉冲刷新。
:return: None
"""
self.set_interval(0.8, self._advance_pulse, name='signal rail pulse')
def _advance_pulse(self) -> None:
"""
推进信号带脉冲状态。
:return: None
"""
self._pulse_index = (self._pulse_index + 1) % 4
self.update(self._build_render_text())
def _build_render_text(self) -> str:
"""
构建信号带渲染文本。
:return: 渲染文本
"""
return self.rendering.build_signal_rail_text(self.lines, self._pulse_index)
File diff suppressed because it is too large Load Diff