feat: add ProcessDock process and port manager
This commit is contained in:
764
processdock/ui.py
Normal file
764
processdock/ui.py
Normal file
@@ -0,0 +1,764 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QColor, QFont, QGuiApplication, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QStyle,
|
||||
QTableView,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from processdock.models import (
|
||||
PROCESS_ROLE,
|
||||
ActionButtonDelegate,
|
||||
ProcessFilterProxyModel,
|
||||
ProcessTableModel,
|
||||
)
|
||||
from processdock.services import ActionResult, ProcessDockError, ProcessService
|
||||
from processdock.styles import APP_STYLE
|
||||
|
||||
|
||||
class WorkerSignals(QObject):
|
||||
result = Signal(object)
|
||||
error = Signal(str)
|
||||
finished = Signal()
|
||||
|
||||
|
||||
class FunctionWorker(QRunnable):
|
||||
def __init__(self, function: Callable[[], Any]) -> None:
|
||||
super().__init__()
|
||||
self.function = function
|
||||
self.signals = WorkerSignals()
|
||||
|
||||
@Slot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.function()
|
||||
except Exception as error: # delivered to the GUI thread
|
||||
self.signals.error.emit(str(error) or error.__class__.__name__)
|
||||
else:
|
||||
self.signals.result.emit(result)
|
||||
finally:
|
||||
self.signals.finished.emit()
|
||||
|
||||
|
||||
class SummaryCard(QFrame):
|
||||
def __init__(self, icon: str, title: str, value: str, subtitle: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SummaryCard")
|
||||
self.setMinimumHeight(92)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 13, 16, 13)
|
||||
layout.setSpacing(12)
|
||||
|
||||
icon_label = QLabel(icon)
|
||||
icon_label.setObjectName("CardIcon")
|
||||
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon_label)
|
||||
|
||||
text_layout = QVBoxLayout()
|
||||
text_layout.setSpacing(1)
|
||||
title_label = QLabel(title)
|
||||
title_label.setObjectName("CardTitle")
|
||||
self.value_label = QLabel(value)
|
||||
self.value_label.setObjectName("CardValue")
|
||||
self.subtitle_label = QLabel(subtitle)
|
||||
self.subtitle_label.setObjectName("CardSubtitle")
|
||||
text_layout.addWidget(title_label)
|
||||
text_layout.addWidget(self.value_label)
|
||||
text_layout.addWidget(self.subtitle_label)
|
||||
layout.addLayout(text_layout, 1)
|
||||
|
||||
def update_value(self, value: str, subtitle: str | None = None) -> None:
|
||||
self.value_label.setText(value)
|
||||
if subtitle is not None:
|
||||
self.subtitle_label.setText(subtitle)
|
||||
|
||||
|
||||
class DetailRow(QWidget):
|
||||
def __init__(self, key: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
key_label = QLabel(key)
|
||||
key_label.setObjectName("DetailKey")
|
||||
key_label.setFixedWidth(76)
|
||||
key_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
self.value_label = QLabel("—")
|
||||
self.value_label.setObjectName("DetailValue")
|
||||
self.value_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.value_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.value_label.setWordWrap(True)
|
||||
layout.addWidget(key_label)
|
||||
layout.addWidget(self.value_label, 1)
|
||||
|
||||
def set_value(self, value: str) -> None:
|
||||
self.value_label.setText(value or "—")
|
||||
|
||||
|
||||
class DetailPanel(QFrame):
|
||||
open_requested = Signal(object)
|
||||
copy_requested = Signal(str)
|
||||
terminate_requested = Signal(object, bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DetailCard")
|
||||
self.setMinimumWidth(350)
|
||||
self.setMaximumWidth(430)
|
||||
self._process: dict[str, Any] | None = None
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
|
||||
heading = QWidget()
|
||||
heading_layout = QHBoxLayout(heading)
|
||||
heading_layout.setContentsMargins(18, 16, 18, 12)
|
||||
title = QLabel("进程详情")
|
||||
title.setObjectName("SectionTitle")
|
||||
hint = QLabel("实时信息")
|
||||
hint.setObjectName("MutedLabel")
|
||||
heading_layout.addWidget(title)
|
||||
heading_layout.addStretch()
|
||||
heading_layout.addWidget(hint)
|
||||
outer.addWidget(heading)
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
outer.addWidget(self.stack, 1)
|
||||
self._build_empty_page()
|
||||
self._build_detail_page()
|
||||
self.stack.setCurrentWidget(self.empty_page)
|
||||
|
||||
def _build_empty_page(self) -> None:
|
||||
self.empty_page = QWidget()
|
||||
layout = QVBoxLayout(self.empty_page)
|
||||
layout.setContentsMargins(34, 34, 34, 70)
|
||||
layout.addStretch()
|
||||
icon = QLabel("⌁")
|
||||
icon.setObjectName("EmptyIcon")
|
||||
icon.setFixedSize(54, 54)
|
||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_title = QLabel("选择一个进程")
|
||||
self.empty_title.setObjectName("EmptyTitle")
|
||||
self.empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_hint = QLabel("点击左侧任意一行,查看路径、命令行和资源占用。")
|
||||
self.empty_hint.setObjectName("MutedLabel")
|
||||
self.empty_hint.setWordWrap(True)
|
||||
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addSpacing(12)
|
||||
layout.addWidget(self.empty_title)
|
||||
layout.addSpacing(4)
|
||||
layout.addWidget(self.empty_hint)
|
||||
layout.addStretch()
|
||||
self.stack.addWidget(self.empty_page)
|
||||
|
||||
def _build_detail_page(self) -> None:
|
||||
self.detail_page = QWidget()
|
||||
page_layout = QVBoxLayout(self.detail_page)
|
||||
page_layout.setContentsMargins(0, 0, 0, 0)
|
||||
page_layout.setSpacing(0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
detail_content = QWidget()
|
||||
layout = QVBoxLayout(detail_content)
|
||||
layout.setContentsMargins(18, 6, 18, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
identity = QWidget()
|
||||
identity_layout = QHBoxLayout(identity)
|
||||
identity_layout.setContentsMargins(0, 0, 0, 6)
|
||||
identity_layout.setSpacing(12)
|
||||
self.avatar = QLabel("P")
|
||||
self.avatar.setObjectName("ProcessAvatar")
|
||||
self.avatar.setFixedSize(50, 50)
|
||||
self.avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
name_layout = QVBoxLayout()
|
||||
name_layout.setSpacing(4)
|
||||
self.process_name = QLabel("—")
|
||||
self.process_name.setObjectName("ProcessName")
|
||||
self.process_name.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.pid_badge = QLabel("PID —")
|
||||
self.pid_badge.setObjectName("PidBadge")
|
||||
self.pid_badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
name_layout.addWidget(self.process_name)
|
||||
name_layout.addWidget(self.pid_badge)
|
||||
identity_layout.addWidget(self.avatar)
|
||||
identity_layout.addLayout(name_layout, 1)
|
||||
layout.addWidget(identity)
|
||||
|
||||
self.limited_banner = QLabel("部分信息受系统权限限制")
|
||||
self.limited_banner.setObjectName("LimitedBanner")
|
||||
self.limited_banner.setWordWrap(True)
|
||||
self.limited_banner.hide()
|
||||
layout.addWidget(self.limited_banner)
|
||||
|
||||
self.rows: dict[str, DetailRow] = {}
|
||||
for key, label in (
|
||||
("ports", "监听端口"),
|
||||
("exe", "程序路径"),
|
||||
("create_time", "启动时间"),
|
||||
("cpu", "CPU 使用率"),
|
||||
("memory", "内存占用"),
|
||||
("username", "运行用户"),
|
||||
("status", "进程状态"),
|
||||
("ppid", "父进程 PID"),
|
||||
):
|
||||
row = DetailRow(label)
|
||||
if key == "ports":
|
||||
row.value_label.setObjectName("PortValue")
|
||||
self.rows[key] = row
|
||||
layout.addWidget(row)
|
||||
|
||||
command_title = QLabel("启动命令")
|
||||
command_title.setObjectName("DetailKey")
|
||||
layout.addWidget(command_title)
|
||||
self.command_box = QTextEdit()
|
||||
self.command_box.setObjectName("CommandBox")
|
||||
self.command_box.setReadOnly(True)
|
||||
self.command_box.setMinimumHeight(76)
|
||||
self.command_box.setMaximumHeight(108)
|
||||
layout.addWidget(self.command_box)
|
||||
layout.addStretch()
|
||||
|
||||
actions = QWidget()
|
||||
actions_layout = QVBoxLayout(actions)
|
||||
actions_layout.setContentsMargins(18, 10, 18, 18)
|
||||
actions_layout.setSpacing(8)
|
||||
utility_layout = QHBoxLayout()
|
||||
utility_layout.setSpacing(8)
|
||||
self.open_button = QPushButton("打开文件位置")
|
||||
self.open_button.setObjectName("SecondaryButton")
|
||||
self.copy_button = QPushButton("复制启动命令")
|
||||
self.copy_button.setObjectName("SecondaryButton")
|
||||
utility_layout.addWidget(self.open_button)
|
||||
utility_layout.addWidget(self.copy_button)
|
||||
actions_layout.addLayout(utility_layout)
|
||||
|
||||
danger_layout = QHBoxLayout()
|
||||
danger_layout.setSpacing(8)
|
||||
self.normal_button = QPushButton("正常结束")
|
||||
self.normal_button.setObjectName("SoftDangerButton")
|
||||
self.force_button = QPushButton("强制结束")
|
||||
self.force_button.setObjectName("DangerButton")
|
||||
danger_layout.addWidget(self.normal_button)
|
||||
danger_layout.addWidget(self.force_button)
|
||||
actions_layout.addLayout(danger_layout)
|
||||
|
||||
self.open_button.clicked.connect(self._request_open)
|
||||
self.copy_button.clicked.connect(self._request_copy)
|
||||
self.normal_button.clicked.connect(lambda: self._request_terminate(False))
|
||||
self.force_button.clicked.connect(lambda: self._request_terminate(True))
|
||||
|
||||
scroll.setWidget(detail_content)
|
||||
page_layout.addWidget(scroll, 1)
|
||||
page_layout.addWidget(actions)
|
||||
self.stack.addWidget(self.detail_page)
|
||||
self.detail_scroll = scroll
|
||||
|
||||
def set_process(self, process: dict[str, Any]) -> None:
|
||||
self._process = process
|
||||
name = process["name"]
|
||||
self.avatar.setText((name[:1] or "P").upper())
|
||||
self.process_name.setText(name)
|
||||
self.pid_badge.setText(f'PID {process["pid"]}')
|
||||
self.limited_banner.setVisible(bool(process["access_limited"]))
|
||||
|
||||
ports = "、".join(str(port) for port in process["ports"]) or "未监听端口"
|
||||
port_tooltip = "\n".join(
|
||||
f'{item["protocol"]} {item["address"]}:{item["port"]}'
|
||||
for item in process["port_details"]
|
||||
)
|
||||
self.rows["ports"].set_value(ports)
|
||||
self.rows["ports"].value_label.setToolTip(port_tooltip)
|
||||
self.rows["exe"].set_value(process["exe"])
|
||||
self.rows["create_time"].set_value(process["create_time_text"])
|
||||
self.rows["cpu"].set_value(f'{process["cpu_percent"]:.1f}%')
|
||||
self.rows["memory"].set_value(
|
||||
f'{process["memory_text"]} ({process["memory_percent"]:.1f}%)'
|
||||
)
|
||||
self.rows["username"].set_value(process["username"])
|
||||
self.rows["status"].set_value(process["status_label"])
|
||||
self.rows["ppid"].set_value(str(process["ppid"]) if process["ppid"] else "—")
|
||||
self.command_box.setPlainText(process["command"] or "—")
|
||||
self.open_button.setEnabled(bool(process["can_open_location"]))
|
||||
self.copy_button.setEnabled(bool(process["command"]))
|
||||
self.normal_button.setEnabled(bool(process["can_terminate"]))
|
||||
self.force_button.setEnabled(bool(process["can_terminate"]))
|
||||
self.normal_button.setToolTip(
|
||||
"向进程发送常规结束请求" if process["can_terminate"] else "不能结束 ProcessDock 自身"
|
||||
)
|
||||
self.force_button.setToolTip("立即终止进程,未保存的数据可能丢失")
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def clear(self, message: str = "点击左侧任意一行,查看路径、命令行和资源占用。") -> None:
|
||||
self._process = None
|
||||
self.empty_hint.setText(message)
|
||||
self.stack.setCurrentWidget(self.empty_page)
|
||||
|
||||
def set_action_busy(self, busy: bool) -> None:
|
||||
if not self._process:
|
||||
return
|
||||
self.open_button.setEnabled(not busy and bool(self._process["can_open_location"]))
|
||||
self.copy_button.setEnabled(not busy and bool(self._process["command"]))
|
||||
self.normal_button.setEnabled(not busy and bool(self._process["can_terminate"]))
|
||||
self.force_button.setEnabled(not busy and bool(self._process["can_terminate"]))
|
||||
|
||||
def _request_open(self) -> None:
|
||||
if self._process:
|
||||
self.open_requested.emit(self._process)
|
||||
|
||||
def _request_copy(self) -> None:
|
||||
if self._process and self._process["command"]:
|
||||
self.copy_requested.emit(self._process["command"])
|
||||
|
||||
def _request_terminate(self, force: bool) -> None:
|
||||
if self._process:
|
||||
self.terminate_requested.emit(self._process, force)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(
|
||||
self, service: ProcessService | None = None, auto_refresh: bool = True, parent=None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.service = service or ProcessService()
|
||||
self.thread_pool = QThreadPool.globalInstance()
|
||||
self._workers: set[FunctionWorker] = set()
|
||||
self._refreshing = False
|
||||
self._refresh_pending = False
|
||||
self._closing = False
|
||||
self._selected_pid: int | None = None
|
||||
self._action_busy = False
|
||||
|
||||
self.setWindowTitle("ProcessDock · 进程与端口管理器")
|
||||
self.setWindowIcon(make_app_icon())
|
||||
self.setMinimumSize(1080, 700)
|
||||
self.resize(1360, 820)
|
||||
self.setStyleSheet(APP_STYLE)
|
||||
self._build_ui(auto_refresh)
|
||||
|
||||
self.auto_timer = QTimer(self)
|
||||
self.auto_timer.setInterval(3000)
|
||||
self.auto_timer.timeout.connect(self.refresh)
|
||||
self.auto_checkbox.toggled.connect(self._toggle_auto_refresh)
|
||||
if auto_refresh:
|
||||
self.auto_timer.start()
|
||||
QTimer.singleShot(0, self.refresh)
|
||||
|
||||
def _build_ui(self, auto_refresh: bool) -> None:
|
||||
root = QWidget()
|
||||
root.setObjectName("AppRoot")
|
||||
self.setCentralWidget(root)
|
||||
outer = QVBoxLayout(root)
|
||||
outer.setContentsMargins(20, 18, 20, 16)
|
||||
outer.setSpacing(12)
|
||||
|
||||
header = QFrame()
|
||||
header.setObjectName("HeaderCard")
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(16, 11, 16, 11)
|
||||
header_layout.setSpacing(12)
|
||||
mark = QLabel("P")
|
||||
mark.setObjectName("BrandMark")
|
||||
mark.setFixedSize(42, 42)
|
||||
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
header_layout.addWidget(mark)
|
||||
brand = QVBoxLayout()
|
||||
brand.setSpacing(0)
|
||||
title = QLabel("ProcessDock")
|
||||
title.setObjectName("BrandTitle")
|
||||
subtitle = QLabel("进程与端口管理器")
|
||||
subtitle.setObjectName("BrandSubtitle")
|
||||
brand.addWidget(title)
|
||||
brand.addWidget(subtitle)
|
||||
header_layout.addLayout(brand)
|
||||
header_layout.addStretch()
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setObjectName("SearchBox")
|
||||
self.search_box.setPlaceholderText("搜索进程、PID、端口或路径")
|
||||
self.search_box.setClearButtonEnabled(True)
|
||||
self.search_box.textChanged.connect(self._filter_changed)
|
||||
header_layout.addWidget(self.search_box)
|
||||
|
||||
self.refresh_button = QPushButton("刷新")
|
||||
self.refresh_button.setObjectName("PrimaryButton")
|
||||
self.refresh_button.setIcon(
|
||||
self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)
|
||||
)
|
||||
self.refresh_button.clicked.connect(self.refresh)
|
||||
header_layout.addWidget(self.refresh_button)
|
||||
self.auto_checkbox = QCheckBox("自动刷新 3 秒")
|
||||
self.auto_checkbox.setChecked(auto_refresh)
|
||||
header_layout.addWidget(self.auto_checkbox)
|
||||
outer.addWidget(header)
|
||||
|
||||
cards_layout = QHBoxLayout()
|
||||
cards_layout.setSpacing(10)
|
||||
self.process_card = SummaryCard("进", "运行进程", "—", "正在读取")
|
||||
self.port_card = SummaryCard("端", "监听端口", "—", "TCP 与 UDP")
|
||||
self.cpu_card = SummaryCard("CPU", "系统 CPU", "—", "所有逻辑核心")
|
||||
self.memory_card = SummaryCard("存", "系统内存", "—", "正在读取")
|
||||
for card in (self.process_card, self.port_card, self.cpu_card, self.memory_card):
|
||||
cards_layout.addWidget(card, 1)
|
||||
outer.addLayout(cards_layout)
|
||||
|
||||
self.warning_banner = QLabel()
|
||||
self.warning_banner.setObjectName("WarningBanner")
|
||||
self.warning_banner.setWordWrap(True)
|
||||
self.warning_banner.hide()
|
||||
outer.addWidget(self.warning_banner)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
outer.addWidget(splitter, 1)
|
||||
|
||||
table_card = QFrame()
|
||||
table_card.setObjectName("TableCard")
|
||||
table_layout = QVBoxLayout(table_card)
|
||||
table_layout.setContentsMargins(0, 0, 0, 0)
|
||||
table_layout.setSpacing(0)
|
||||
table_heading = QWidget()
|
||||
table_heading_layout = QHBoxLayout(table_heading)
|
||||
table_heading_layout.setContentsMargins(18, 14, 18, 11)
|
||||
table_title = QLabel("正在运行的进程")
|
||||
table_title.setObjectName("SectionTitle")
|
||||
self.count_label = QLabel("准备读取…")
|
||||
self.count_label.setObjectName("MutedLabel")
|
||||
table_heading_layout.addWidget(table_title)
|
||||
table_heading_layout.addStretch()
|
||||
table_heading_layout.addWidget(self.count_label)
|
||||
table_layout.addWidget(table_heading)
|
||||
|
||||
self.loading_bar = QProgressBar()
|
||||
self.loading_bar.setObjectName("LoadingBar")
|
||||
self.loading_bar.setRange(0, 0)
|
||||
self.loading_bar.setTextVisible(False)
|
||||
self.loading_bar.hide()
|
||||
table_layout.addWidget(self.loading_bar)
|
||||
|
||||
self.model = ProcessTableModel(self)
|
||||
self.proxy = ProcessFilterProxyModel(self)
|
||||
self.proxy.setSourceModel(self.model)
|
||||
self.table = QTableView()
|
||||
self.table.setModel(self.proxy)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.table.setShowGrid(False)
|
||||
self.table.setWordWrap(False)
|
||||
self.table.setSortingEnabled(True)
|
||||
self.table.sortByColumn(1, Qt.SortOrder.AscendingOrder)
|
||||
self.table.verticalHeader().hide()
|
||||
self.table.verticalHeader().setDefaultSectionSize(48)
|
||||
header_view = self.table.horizontalHeader()
|
||||
header_view.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
header_view.setMinimumSectionSize(62)
|
||||
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||
header_view.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
widths = (78, 190, 135, 82, 105, 130, 88, 82)
|
||||
for column, width in enumerate(widths):
|
||||
self.table.setColumnWidth(column, width)
|
||||
self.table.selectionModel().currentRowChanged.connect(self._selection_changed)
|
||||
self.action_delegate = ActionButtonDelegate(self.table)
|
||||
self.action_delegate.terminate_requested.connect(
|
||||
lambda process: self._confirm_terminate(process, False)
|
||||
)
|
||||
self.table.setItemDelegateForColumn(7, self.action_delegate)
|
||||
|
||||
self.table_stack = QStackedWidget()
|
||||
self.table_stack.addWidget(self.table)
|
||||
self.no_results_page = self._build_no_results_page()
|
||||
self.table_stack.addWidget(self.no_results_page)
|
||||
table_layout.addWidget(self.table_stack, 1)
|
||||
splitter.addWidget(table_card)
|
||||
|
||||
self.detail_panel = DetailPanel()
|
||||
self.detail_panel.open_requested.connect(self._open_location)
|
||||
self.detail_panel.copy_requested.connect(self._copy_command)
|
||||
self.detail_panel.terminate_requested.connect(self._confirm_terminate)
|
||||
splitter.addWidget(self.detail_panel)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 0)
|
||||
splitter.setSizes([910, 390])
|
||||
|
||||
footer = QHBoxLayout()
|
||||
self.footer_status = QLabel("就绪")
|
||||
self.footer_status.setObjectName("FooterStatus")
|
||||
self.footer_time = QLabel("尚未刷新")
|
||||
self.footer_time.setObjectName("FooterStatus")
|
||||
footer.addWidget(self.footer_status)
|
||||
footer.addStretch()
|
||||
footer.addWidget(self.footer_time)
|
||||
outer.addLayout(footer)
|
||||
|
||||
@staticmethod
|
||||
def _build_no_results_page() -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.addStretch()
|
||||
icon = QLabel("⌕")
|
||||
icon.setObjectName("EmptyIcon")
|
||||
icon.setFixedSize(54, 54)
|
||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title = QLabel("没有匹配的进程")
|
||||
title.setObjectName("EmptyTitle")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint = QLabel("换一个进程名称、PID 或端口试试。")
|
||||
hint.setObjectName("MutedLabel")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addSpacing(10)
|
||||
layout.addWidget(title)
|
||||
layout.addWidget(hint)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
@Slot()
|
||||
def refresh(self) -> None:
|
||||
if self._refreshing:
|
||||
self._refresh_pending = True
|
||||
return
|
||||
self._refreshing = True
|
||||
self.refresh_button.setEnabled(False)
|
||||
self.loading_bar.show()
|
||||
self._set_status("正在读取进程和端口…")
|
||||
|
||||
worker = FunctionWorker(self.service.snapshot)
|
||||
worker.signals.result.connect(self._apply_snapshot)
|
||||
worker.signals.error.connect(self._refresh_failed)
|
||||
worker.signals.finished.connect(self._refresh_finished)
|
||||
self._track_worker(worker)
|
||||
self.thread_pool.start(worker)
|
||||
|
||||
@Slot(object)
|
||||
def _apply_snapshot(self, snapshot: dict[str, Any]) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
processes = snapshot["processes"]
|
||||
self.model.set_processes(processes)
|
||||
summary = snapshot["summary"]
|
||||
self.process_card.update_value(str(summary["process_count"]), "本机可见进程")
|
||||
self.port_card.update_value(str(summary["port_count"]), "TCP 监听 / UDP 绑定")
|
||||
self.cpu_card.update_value(f'{summary["cpu_percent"]:.1f}%', "所有逻辑核心")
|
||||
self.memory_card.update_value(
|
||||
f'{summary["memory_percent"]:.1f}%',
|
||||
f'{summary["memory_used"]} / {summary["memory_total"]}',
|
||||
)
|
||||
warnings = snapshot.get("warnings") or []
|
||||
self.warning_banner.setText(" ".join(warnings))
|
||||
self.warning_banner.setVisible(bool(warnings))
|
||||
self.footer_time.setText(f'上次刷新 {snapshot["refreshed_at"]}')
|
||||
self._set_status("数据已更新")
|
||||
self._update_visible_count()
|
||||
|
||||
if self._selected_pid is not None:
|
||||
selected = next(
|
||||
(process for process in processes if process["pid"] == self._selected_pid),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
self._selected_pid = None
|
||||
self.detail_panel.clear("该进程已经结束,请重新选择。")
|
||||
else:
|
||||
self.detail_panel.set_process(selected)
|
||||
self._reselect_process(self._selected_pid)
|
||||
|
||||
@Slot(str)
|
||||
def _refresh_failed(self, message: str) -> None:
|
||||
self.warning_banner.setText(f"刷新失败:{message}")
|
||||
self.warning_banner.show()
|
||||
self._set_status("刷新失败")
|
||||
|
||||
@Slot()
|
||||
def _refresh_finished(self) -> None:
|
||||
self._refreshing = False
|
||||
self.refresh_button.setEnabled(True)
|
||||
self.loading_bar.hide()
|
||||
if self._refresh_pending and not self._closing:
|
||||
self._refresh_pending = False
|
||||
QTimer.singleShot(0, self.refresh)
|
||||
|
||||
def _track_worker(self, worker: FunctionWorker) -> None:
|
||||
self._workers.add(worker)
|
||||
worker.signals.finished.connect(lambda current=worker: self._workers.discard(current))
|
||||
|
||||
@Slot(str)
|
||||
def _filter_changed(self, query: str) -> None:
|
||||
self.proxy.set_query(query)
|
||||
self._update_visible_count()
|
||||
|
||||
def _update_visible_count(self) -> None:
|
||||
visible = self.proxy.rowCount()
|
||||
total = self.model.rowCount()
|
||||
self.count_label.setText(
|
||||
f"{visible} / {total} 个进程" if self.search_box.text().strip() else f"{total} 个进程"
|
||||
)
|
||||
self.table_stack.setCurrentWidget(self.table if visible else self.no_results_page)
|
||||
|
||||
@Slot(QModelIndex, QModelIndex)
|
||||
def _selection_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
|
||||
del previous
|
||||
if not current.isValid():
|
||||
return
|
||||
process = current.data(PROCESS_ROLE)
|
||||
if process:
|
||||
self._selected_pid = process["pid"]
|
||||
self.detail_panel.set_process(process)
|
||||
|
||||
def _reselect_process(self, pid: int) -> None:
|
||||
for source_row, process in enumerate(self.model.processes):
|
||||
if process["pid"] != pid:
|
||||
continue
|
||||
source_index = self.model.index(source_row, 0)
|
||||
proxy_index = self.proxy.mapFromSource(source_index)
|
||||
if proxy_index.isValid():
|
||||
self.table.selectRow(proxy_index.row())
|
||||
return
|
||||
|
||||
@Slot(bool)
|
||||
def _toggle_auto_refresh(self, enabled: bool) -> None:
|
||||
if enabled:
|
||||
self.auto_timer.start()
|
||||
self._set_status("已开启自动刷新")
|
||||
else:
|
||||
self.auto_timer.stop()
|
||||
self._set_status("已暂停自动刷新")
|
||||
|
||||
@Slot(object, bool)
|
||||
def _confirm_terminate(self, process: dict[str, Any], force: bool) -> None:
|
||||
if self._action_busy or not process.get("can_terminate"):
|
||||
return
|
||||
name = process["name"]
|
||||
pid = process["pid"]
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
box.setWindowTitle("确认强制结束" if force else "确认结束进程")
|
||||
box.setText(f"确定要结束 {name}(PID {pid})吗?")
|
||||
box.setInformativeText(
|
||||
"强制结束可能导致未保存的数据丢失。"
|
||||
if force
|
||||
else "将先发送常规结束请求;如果进程没有响应,可再强制结束。"
|
||||
)
|
||||
confirm = box.addButton(
|
||||
"强制结束" if force else "正常结束", QMessageBox.ButtonRole.AcceptRole
|
||||
)
|
||||
box.addButton("取消", QMessageBox.ButtonRole.RejectRole)
|
||||
box.setDefaultButton(confirm)
|
||||
box.exec()
|
||||
if box.clickedButton() is not confirm:
|
||||
return
|
||||
|
||||
self._action_busy = True
|
||||
if self._selected_pid == pid:
|
||||
self.detail_panel.set_action_busy(True)
|
||||
self._set_status(f"正在结束 {name}…")
|
||||
worker = FunctionWorker(
|
||||
lambda: self.service.terminate(
|
||||
pid,
|
||||
force=force,
|
||||
expected_create_time=process.get("create_time"),
|
||||
)
|
||||
)
|
||||
worker.signals.result.connect(self._termination_finished)
|
||||
worker.signals.error.connect(self._action_failed)
|
||||
worker.signals.finished.connect(self._action_worker_finished)
|
||||
self._track_worker(worker)
|
||||
self.thread_pool.start(worker)
|
||||
|
||||
@Slot(object)
|
||||
def _termination_finished(self, result: ActionResult) -> None:
|
||||
self._set_status(result.message)
|
||||
if result.still_running:
|
||||
QMessageBox.warning(self, "进程仍在运行", result.message)
|
||||
self.refresh()
|
||||
|
||||
@Slot(str)
|
||||
def _action_failed(self, message: str) -> None:
|
||||
self._set_status("操作失败")
|
||||
QMessageBox.critical(self, "操作失败", message)
|
||||
|
||||
@Slot()
|
||||
def _action_worker_finished(self) -> None:
|
||||
self._action_busy = False
|
||||
self.detail_panel.set_action_busy(False)
|
||||
|
||||
@Slot(object)
|
||||
def _open_location(self, process: dict[str, Any]) -> None:
|
||||
if self._action_busy:
|
||||
return
|
||||
self._action_busy = True
|
||||
self.detail_panel.set_action_busy(True)
|
||||
worker = FunctionWorker(
|
||||
lambda: self.service.open_file_location(
|
||||
process["pid"], expected_create_time=process.get("create_time")
|
||||
)
|
||||
)
|
||||
worker.signals.result.connect(lambda result: self._set_status(result.message))
|
||||
worker.signals.error.connect(self._action_failed)
|
||||
worker.signals.finished.connect(self._action_worker_finished)
|
||||
self._track_worker(worker)
|
||||
self.thread_pool.start(worker)
|
||||
|
||||
@Slot(str)
|
||||
def _copy_command(self, command: str) -> None:
|
||||
QGuiApplication.clipboard().setText(command)
|
||||
self._set_status("启动命令已复制到剪贴板")
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.footer_status.setText(text)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self._closing = True
|
||||
self.auto_timer.stop()
|
||||
self.thread_pool.clear()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def make_app_icon() -> QIcon:
|
||||
pixmap = QPixmap(64, 64)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#5B5BD6"))
|
||||
painter.drawRoundedRect(4, 4, 56, 56, 15, 15)
|
||||
painter.setPen(QColor("#FFFFFF"))
|
||||
font = QFont("Arial", 29, QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "P")
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def create_window_for_testing(service: ProcessService | None = None) -> MainWindow:
|
||||
"""Small test hook used by the off-screen smoke check."""
|
||||
if QApplication.instance() is None:
|
||||
raise RuntimeError("A QApplication must exist before creating the window.")
|
||||
return MainWindow(service=service, auto_refresh=False)
|
||||
Reference in New Issue
Block a user