feat: add ProcessDock process and port manager

This commit is contained in:
王鹏
2026-07-17 09:14:45 +08:00
commit 13bb37585f
22 changed files with 2201 additions and 0 deletions

3
processdock/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""ProcessDock - a local process and port manager."""
__version__ = "1.0.0"

187
processdock/models.py Normal file
View File

@@ -0,0 +1,187 @@
from __future__ import annotations
from typing import Any
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QRect, QSortFilterProxyModel, Qt, Signal
from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter
from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem
from processdock.services import matches_process
PROCESS_ROLE = Qt.ItemDataRole.UserRole + 1
SORT_ROLE = Qt.ItemDataRole.UserRole + 2
class ProcessTableModel(QAbstractTableModel):
columns = ("PID", "进程名称", "监听端口", "CPU", "内存", "用户", "状态", "操作")
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._processes: list[dict[str, Any]] = []
def set_processes(self, processes: list[dict[str, Any]]) -> None:
self.beginResetModel()
self._processes = list(processes)
self.endResetModel()
@property
def processes(self) -> list[dict[str, Any]]:
return self._processes
def rowCount(self, parent=QModelIndex()) -> int: # noqa: N802
return 0 if parent.isValid() else len(self._processes)
def columnCount(self, parent=QModelIndex()) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.columns)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): # noqa: N802
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self.columns[section]
return None
def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid() or not 0 <= index.row() < len(self._processes):
return None
process = self._processes[index.row()]
column = index.column()
if role == PROCESS_ROLE:
return process
if role == SORT_ROLE:
values = (
process["pid"],
process["name"].casefold(),
process["ports"][0] if process["ports"] else -1,
process["cpu_percent"],
process["memory_bytes"],
process["username"].casefold(),
process["status_label"],
process["pid"],
)
return values[column]
if role == Qt.ItemDataRole.DisplayRole:
ports = ", ".join(str(port) for port in process["ports"]) or ""
values = (
str(process["pid"]),
process["name"],
ports,
f'{process["cpu_percent"]:.1f}%',
process["memory_text"],
process["username"],
process["status_label"],
"当前应用" if process["is_current"] else "结束",
)
return values[column]
if role == Qt.ItemDataRole.TextAlignmentRole:
if column in (0, 2, 3, 4, 6, 7):
return Qt.AlignmentFlag.AlignCenter
return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
if role == Qt.ItemDataRole.ForegroundRole:
if column == 6:
if process["status"] == "running":
return QColor("#129B72")
if process["status"] in {"stopped", "suspended"}:
return QColor("#D88918")
if column == 2 and process["ports"]:
return QColor("#5B5BD6")
if column == 7:
return QColor("#A73A48" if process["can_terminate"] else "#9AA3B2")
if role == Qt.ItemDataRole.FontRole:
font = QFont()
if column in (0, 2, 3, 4):
font.setFamily("Cascadia Mono")
font.setPointSize(9)
if column == 1:
font.setWeight(QFont.Weight.DemiBold)
return font
if role == Qt.ItemDataRole.ToolTipRole:
if column == 2 and process["port_details"]:
return "\n".join(
f'{item["protocol"]} {item["address"]}:{item["port"]}'
for item in process["port_details"]
)
if column == 1:
return process["exe"] or process["name"]
if column == 7 and process["is_current"]:
return "ProcessDock 不允许结束自身"
return None
class ProcessFilterProxyModel(QSortFilterProxyModel):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._query = ""
self.setDynamicSortFilter(True)
self.setSortRole(SORT_ROLE)
def set_query(self, query: str) -> None:
self.beginFilterChange()
self._query = query
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: # noqa: N802
model = self.sourceModel()
index = model.index(source_row, 0, source_parent)
process = model.data(index, PROCESS_ROLE)
return bool(process and matches_process(process, self._query))
def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: # noqa: N802
left_value = self.sourceModel().data(left, SORT_ROLE)
right_value = self.sourceModel().data(right, SORT_ROLE)
return left_value < right_value
class ActionButtonDelegate(QStyledItemDelegate):
terminate_requested = Signal(object)
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
process = index.data(PROCESS_ROLE)
if not process:
return super().paint(painter, option, index)
painter.save()
selected = bool(option.state & option.state.State_Selected)
if selected:
painter.fillRect(option.rect, QColor("#EEF0FF"))
rect = self._button_rect(option.rect)
enabled = bool(process["can_terminate"])
fill = QColor("#FFF0F1") if enabled else QColor("#F2F4F7")
text_color = QColor("#B53C4A") if enabled else QColor("#9AA3B2")
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(fill)
painter.drawRoundedRect(rect, 7, 7)
painter.setPen(text_color)
font = painter.font()
font.setPointSize(9)
font.setWeight(QFont.Weight.DemiBold)
painter.setFont(font)
text = "结束" if enabled else "当前应用"
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
painter.restore()
def editorEvent(self, event, model, option, index) -> bool: # noqa: N802
process = index.data(PROCESS_ROLE)
if (
process
and process["can_terminate"]
and isinstance(event, QMouseEvent)
and event.type() == QMouseEvent.Type.MouseButtonRelease
and event.button() == Qt.MouseButton.LeftButton
and self._button_rect(option.rect).contains(event.position().toPoint())
):
self.terminate_requested.emit(process)
return True
return super().editorEvent(event, model, option, index)
@staticmethod
def _button_rect(cell: QRect) -> QRect:
width, height = 58, 30
return QRect(
cell.center().x() - width // 2,
cell.center().y() - height // 2,
width,
height,
)

499
processdock/services.py Normal file
View File

@@ -0,0 +1,499 @@
from __future__ import annotations
import os
import json
import socket
import subprocess
import sys
import threading
import time
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
import psutil
STATUS_LABELS = {
# psutil exposes different status constants on each operating system, while
# the returned values are stable strings. Literal keys keep imports portable.
"running": "运行中",
"sleeping": "休眠",
"disk-sleep": "等待磁盘",
"stopped": "已停止",
"tracing-stop": "调试暂停",
"zombie": "僵尸进程",
"dead": "已结束",
"wake-kill": "唤醒终止",
"waking": "唤醒中",
"parked": "已驻留",
"idle": "空闲",
"locked": "已锁定",
"waiting": "等待中",
"suspended": "已挂起",
}
class ProcessDockError(RuntimeError):
"""Base error with a user-facing message."""
class ProcessGoneError(ProcessDockError):
pass
class ProcessAccessError(ProcessDockError):
pass
class ProtectedProcessError(ProcessDockError):
pass
class ProcessChangedError(ProcessDockError):
pass
@dataclass(frozen=True)
class ActionResult:
success: bool
message: str
still_running: bool = False
def format_command(arguments: list[str] | None) -> str:
if not arguments:
return ""
if os.name == "nt":
return subprocess.list2cmdline(arguments)
return " ".join(_shell_quote(argument) for argument in arguments)
def _shell_quote(value: str) -> str:
if not value:
return "''"
if all(character.isalnum() or character in "@%_-+=:,./" for character in value):
return value
return "'" + value.replace("'", "'\"'\"'") + "'"
def humanize_bytes(size: int | float) -> str:
value = float(max(size, 0))
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
if unit == "B":
return f"{value:.0f} {unit}"
return f"{value:.1f} {unit}"
value /= 1024
return f"{value:.1f} TB"
def matches_process(process: dict[str, Any], query: str) -> bool:
normalized = query.strip().casefold()
if not normalized:
return True
values = [
str(process.get("pid", "")),
str(process.get("name", "")),
str(process.get("username", "")),
str(process.get("exe", "")),
str(process.get("command", "")),
",".join(str(port) for port in process.get("ports", [])),
]
return any(normalized in value.casefold() for value in values)
class ProcessService:
"""Reads process/port data and performs guarded local process actions."""
def __init__(self) -> None:
self.current_pid = os.getpid()
self._cpu_count = max(psutil.cpu_count(logical=True) or 1, 1)
self._cpu_samples: dict[int, tuple[float, float, float]] = {}
self._last_cpu: dict[int, float] = {}
self._lock = threading.RLock()
def snapshot(self) -> dict[str, Any]:
port_map, port_warning = self._collect_ports()
warnings = [port_warning] if port_warning else []
if os.name == "nt":
try:
processes = self._collect_windows_processes(port_map)
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
warnings.append(f"快速采集不可用,已切换兼容模式:{error}")
processes = self._collect_psutil_processes(port_map)
else:
processes = self._collect_psutil_processes(port_map)
processes.sort(key=lambda item: (item["name"].casefold(), item["pid"]))
unique_ports = {
(detail["protocol"], detail["address"], detail["port"])
for process in processes
for detail in process["port_details"]
}
memory = psutil.virtual_memory()
summary = {
"process_count": len(processes),
"port_count": len(unique_ports),
"cpu_percent": round(float(psutil.cpu_percent(interval=None)), 1),
"memory_percent": round(float(memory.percent), 1),
"memory_used": humanize_bytes(memory.used),
"memory_total": humanize_bytes(memory.total),
}
return {
"processes": processes,
"summary": summary,
"warnings": warnings,
"refreshed_at": datetime.now().strftime("%H:%M:%S"),
}
def _collect_psutil_processes(
self, port_map: dict[int, list[dict[str, Any]]]
) -> list[dict[str, Any]]:
now = time.monotonic()
processes: list[dict[str, Any]] = []
live_pids: set[int] = set()
for process in psutil.process_iter():
try:
row = self._read_process(process, port_map.get(process.pid, []), now)
except (psutil.NoSuchProcess, psutil.ZombieProcess):
continue
except Exception:
# A single unusual system process should never break the full refresh.
continue
processes.append(row)
live_pids.add(row["pid"])
with self._lock:
self._cpu_samples = {
pid: sample for pid, sample in self._cpu_samples.items() if pid in live_pids
}
self._last_cpu = {
pid: value for pid, value in self._last_cpu.items() if pid in live_pids
}
return processes
def _collect_windows_processes(
self, port_map: dict[int, list[dict[str, Any]]]
) -> list[dict[str, Any]]:
"""Use Windows CIM's bulk providers to avoid hundreds of slow per-PID calls."""
script = r"""
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$processes = Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object {
$created = 0
if ($_.CreationDate) {
$created = [DateTimeOffset]$_.CreationDate
$created = $created.ToUnixTimeMilliseconds() / 1000
}
[PSCustomObject]@{
pid = [int]$_.ProcessId
ppid = [int]$_.ParentProcessId
name = [string]$_.Name
exe = [string]$_.ExecutablePath
command = [string]$_.CommandLine
created = [double]$created
}
}
$performance = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction Stop |
Where-Object { $_.Name -ne '_Total' } |
ForEach-Object {
[PSCustomObject]@{
pid = [int]$_.IDProcess
cpu = [double]$_.PercentProcessorTime
memory = [int64]$_.WorkingSetPrivate
}
}
[PSCustomObject]@{ processes = @($processes); performance = @($performance) } |
ConvertTo-Json -Depth 4 -Compress
"""
completed = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout).strip()
raise OSError(detail or "PowerShell CIM 查询失败")
payload = json.loads(completed.stdout.lstrip("\ufeff"))
static_by_pid = {
int(item["pid"]): item
for item in self._ensure_list(payload.get("processes"))
if item.get("pid") is not None
}
performance_by_pid = {
int(item["pid"]): item
for item in self._ensure_list(payload.get("performance"))
if item.get("pid") is not None
}
total_memory = max(int(psutil.virtual_memory().total), 1)
processes: list[dict[str, Any]] = []
for pid, values in static_by_pid.items():
if pid == 0:
continue
performance = performance_by_pid.get(pid, {})
memory_bytes = max(int(performance.get("memory") or 0), 0)
raw_cpu = max(float(performance.get("cpu") or 0.0), 0.0)
cpu_percent = round(min(raw_cpu / self._cpu_count, 100.0), 1)
try:
username = psutil.Process(pid).username() or ""
except (psutil.Error, OSError):
username = ""
port_details = port_map.get(pid, [])
ports = sorted({detail["port"] for detail in port_details})
executable = values.get("exe") or ""
command = values.get("command") or ""
create_time = float(values.get("created") or 0.0)
processes.append(
{
"pid": pid,
"name": values.get("name") or f"PID {pid}",
"ports": ports,
"port_details": port_details,
"cpu_percent": cpu_percent,
"memory_bytes": memory_bytes,
"memory_text": humanize_bytes(memory_bytes),
"memory_percent": round(memory_bytes / total_memory * 100, 1),
"username": username,
"status": "running",
"status_label": "运行中",
"exe": executable,
"command": command,
"command_parts": [],
"create_time": create_time,
"create_time_text": self._format_timestamp(create_time),
"ppid": int(values.get("ppid") or 0),
"access_limited": not executable and not command and username == "",
"is_current": pid == self.current_pid,
"can_terminate": pid != self.current_pid,
"can_open_location": bool(executable),
}
)
return processes
@staticmethod
def _ensure_list(value: Any) -> list[Any]:
if value is None:
return []
return value if isinstance(value, list) else [value]
def _collect_ports(self) -> tuple[dict[int, list[dict[str, Any]]], str | None]:
ports: dict[int, set[tuple[str, str, int]]] = defaultdict(set)
try:
connections = psutil.net_connections(kind="inet")
except psutil.AccessDenied:
return {}, "权限不足:无法读取完整端口列表,建议以管理员身份运行。"
except OSError as error:
return {}, f"端口信息暂时不可用:{error}"
for connection in connections:
if connection.pid is None or not connection.laddr:
continue
protocol = "TCP" if connection.type == socket.SOCK_STREAM else "UDP"
if protocol == "TCP" and connection.status != psutil.CONN_LISTEN:
continue
try:
address = connection.laddr.ip
port = int(connection.laddr.port)
except AttributeError:
address, port = connection.laddr[:2]
port = int(port)
if port > 0:
ports[connection.pid].add((protocol, str(address), port))
result = {
pid: [
{"protocol": protocol, "address": address, "port": port}
for protocol, address, port in sorted(
items, key=lambda item: (item[2], item[0], item[1])
)
]
for pid, items in ports.items()
}
return result, None
def _read_process(
self,
process: psutil.Process,
port_details: list[dict[str, Any]],
sample_time: float,
) -> dict[str, Any]:
values = process.as_dict(
attrs=[
"pid",
"name",
"username",
"status",
"exe",
"cmdline",
"create_time",
"ppid",
"memory_info",
"memory_percent",
"cpu_times",
],
ad_value=None,
)
pid = int(values["pid"])
create_time = float(values.get("create_time") or 0.0)
cpu_percent = self._calculate_cpu(
pid, values.get("cpu_times"), create_time, sample_time
)
memory_info = values.get("memory_info")
memory_bytes = int(getattr(memory_info, "rss", 0) or 0)
command_parts = values.get("cmdline") or []
status = str(values.get("status") or "unknown")
ports = sorted({detail["port"] for detail in port_details})
exe = values.get("exe") or ""
username = values.get("username") or ""
limited = not exe and not command_parts and username == ""
return {
"pid": pid,
"name": values.get("name") or f"PID {pid}",
"ports": ports,
"port_details": port_details,
"cpu_percent": cpu_percent,
"memory_bytes": memory_bytes,
"memory_text": humanize_bytes(memory_bytes),
"memory_percent": round(float(values.get("memory_percent") or 0.0), 1),
"username": username,
"status": status,
"status_label": STATUS_LABELS.get(status, "未知"),
"exe": exe,
"command": format_command(command_parts),
"command_parts": command_parts,
"create_time": create_time,
"create_time_text": self._format_timestamp(create_time),
"ppid": int(values.get("ppid") or 0),
"access_limited": limited,
"is_current": pid == self.current_pid,
"can_terminate": pid != self.current_pid,
"can_open_location": bool(exe),
}
def _calculate_cpu(
self,
pid: int,
cpu_times: Any,
create_time: float,
sample_time: float,
) -> float:
if cpu_times is None:
return 0.0
total = float(getattr(cpu_times, "user", 0.0)) + float(
getattr(cpu_times, "system", 0.0)
)
with self._lock:
previous = self._cpu_samples.get(pid)
result = self._last_cpu.get(pid, 0.0)
if previous is not None:
previous_time, previous_total, previous_created = previous
elapsed = sample_time - previous_time
if elapsed >= 0.2 and abs(previous_created - create_time) < 0.01:
used = max(total - previous_total, 0.0)
result = min(max(used / elapsed / self._cpu_count * 100, 0.0), 100.0)
result = round(result, 1)
self._cpu_samples[pid] = (sample_time, total, create_time)
self._last_cpu[pid] = result
return result
@staticmethod
def _format_timestamp(timestamp: float) -> str:
if timestamp <= 0:
return ""
try:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
except (OSError, OverflowError, ValueError):
return ""
def terminate(
self, pid: int, *, force: bool, expected_create_time: float | None = None
) -> ActionResult:
if pid == self.current_pid:
raise ProtectedProcessError("ProcessDock 正在使用该进程,不能结束自身。")
try:
process = psutil.Process(pid)
actual_create_time = process.create_time()
if (
expected_create_time is not None
and expected_create_time > 0
and abs(actual_create_time - expected_create_time) >= 0.01
):
raise ProcessChangedError("PID 已被新的进程复用,请刷新后重试。")
name = process.name()
if force:
process.kill()
elif os.name == "nt":
completed = subprocess.run(
["taskkill", "/PID", str(pid)],
capture_output=True,
text=True,
timeout=5,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
# Console/background processes commonly reject taskkill without /F.
# Treat that as "still running" below so the GUI can offer force,
# rather than presenting a misleading hard failure.
else:
process.terminate()
try:
process.wait(timeout=3)
except psutil.TimeoutExpired:
return ActionResult(
False,
f"{name} 尚未退出,可以尝试强制结束。",
still_running=True,
)
action = "强制结束" if force else "正常结束"
return ActionResult(True, f"{action} {name}PID {pid})。")
except ProtectedProcessError:
raise
except ProcessChangedError:
raise
except psutil.NoSuchProcess as error:
raise ProcessGoneError("进程已经结束。") from error
except psutil.AccessDenied as error:
raise ProcessAccessError("权限不足,请尝试以管理员身份运行 ProcessDock。") from error
except subprocess.TimeoutExpired as error:
raise ProcessDockError("系统结束命令超时,请稍后重试。") from error
def open_file_location(
self, pid: int, *, expected_create_time: float | None = None
) -> ActionResult:
try:
process = psutil.Process(pid)
actual_create_time = process.create_time()
if (
expected_create_time is not None
and expected_create_time > 0
and abs(actual_create_time - expected_create_time) >= 0.01
):
raise ProcessChangedError("PID 已被新的进程复用,请刷新后重试。")
executable = process.exe()
if not executable:
raise ProcessDockError("该进程没有可访问的程序路径。")
path = Path(executable)
if sys.platform == "win32":
subprocess.Popen(["explorer.exe", f"/select,{path}"])
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", str(path)])
else:
subprocess.Popen(["xdg-open", str(path.parent)])
return ActionResult(True, "已打开程序所在位置。")
except ProcessChangedError:
raise
except psutil.NoSuchProcess as error:
raise ProcessGoneError("进程已经结束。") from error
except psutil.AccessDenied as error:
raise ProcessAccessError("权限不足,无法读取程序路径。") from error
except OSError as error:
raise ProcessDockError(f"无法打开文件位置:{error}") from error

198
processdock/styles.py Normal file
View File

@@ -0,0 +1,198 @@
APP_STYLE = r"""
* {
font-family: "Microsoft YaHei UI";
color: #202534;
}
QMainWindow, QWidget#AppRoot {
background: #F4F6FA;
}
QFrame#HeaderCard, QFrame#TableCard, QFrame#DetailCard, QFrame#SummaryCard {
background: #FFFFFF;
border: 1px solid #E6E9F0;
border-radius: 14px;
}
QLabel#BrandMark {
color: white;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #6868E8, stop:1 #4C4CC1);
border-radius: 11px;
font-size: 20px;
font-weight: 700;
}
QLabel#BrandTitle {
color: #171B2A;
font-size: 19px;
font-weight: 700;
}
QLabel#BrandSubtitle, QLabel#MutedLabel, QLabel#CardSubtitle, QLabel#DetailKey {
color: #80899A;
}
QLabel#BrandSubtitle { font-size: 10px; }
QLineEdit#SearchBox {
min-height: 38px;
min-width: 260px;
padding: 0 14px;
background: #F7F8FB;
border: 1px solid #DFE3EB;
border-radius: 9px;
selection-background-color: #6464D9;
}
QLineEdit#SearchBox:focus {
background: #FFFFFF;
border: 1px solid #6262D6;
}
QPushButton {
min-height: 36px;
padding: 0 16px;
border-radius: 9px;
font-weight: 600;
}
QPushButton#PrimaryButton {
color: white;
background: #5B5BD6;
border: 1px solid #5B5BD6;
}
QPushButton#PrimaryButton:hover { background: #4F4FC5; }
QPushButton#SecondaryButton {
background: #FFFFFF;
border: 1px solid #DDE1EA;
}
QPushButton#SecondaryButton:hover { background: #F6F7FB; border-color: #C9CEDA; }
QPushButton#DangerButton {
color: white;
background: #C64B59;
border: 1px solid #C64B59;
}
QPushButton#DangerButton:hover { background: #B33D4B; }
QPushButton#SoftDangerButton {
color: #B53C4A;
background: #FFF1F2;
border: 1px solid #F3CDD2;
}
QPushButton#SoftDangerButton:hover { background: #FDE5E8; }
QPushButton:disabled {
color: #A6ADBA;
background: #F2F4F7;
border-color: #E4E7EC;
}
QCheckBox { spacing: 8px; color: #596273; }
QLabel#CardIcon {
min-width: 38px; max-width: 38px;
min-height: 38px; max-height: 38px;
color: #5858CD;
background: #EEEEFF;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
}
QLabel#CardValue {
color: #1C2130;
font-size: 22px;
font-weight: 700;
}
QLabel#CardTitle { color: #687183; font-size: 11px; }
QLabel#WarningBanner {
color: #8B5A10;
background: #FFF7E5;
border: 1px solid #F2D79A;
border-radius: 9px;
padding: 9px 12px;
}
QLabel#SectionTitle {
color: #202534;
font-size: 15px;
font-weight: 700;
}
QTableView {
background: #FFFFFF;
alternate-background-color: #FBFCFE;
border: none;
gridline-color: transparent;
selection-background-color: #EEF0FF;
selection-color: #202534;
outline: none;
}
QTableView::item {
border-bottom: 1px solid #EEF0F4;
padding: 0 10px;
}
QTableView::item:hover { background: #F7F8FF; }
QHeaderView::section {
color: #70798A;
background: #F7F8FB;
border: none;
border-bottom: 1px solid #E8EAF0;
padding: 9px 8px;
font-size: 10px;
font-weight: 700;
}
QScrollBar:vertical { width: 9px; background: transparent; margin: 3px; }
QScrollBar::handle:vertical { min-height: 30px; background: #CFD4DE; border-radius: 4px; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { height: 9px; background: transparent; margin: 3px; }
QScrollBar::handle:horizontal { min-width: 30px; background: #CFD4DE; border-radius: 4px; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QSplitter::handle { background: transparent; width: 12px; }
QLabel#ProcessAvatar {
color: #FFFFFF;
background: #5B5BD6;
border-radius: 12px;
font-size: 20px;
font-weight: 700;
}
QLabel#ProcessName { font-size: 17px; font-weight: 700; color: #1D2230; }
QLabel#PidBadge {
color: #626B7A;
background: #F0F2F6;
border-radius: 7px;
padding: 3px 7px;
font-family: "Cascadia Mono";
font-size: 9px;
}
QLabel#DetailValue {
color: #242A38;
font-size: 11px;
}
QLabel#PortValue {
color: #4E4EC4;
background: #F0F0FF;
border-radius: 7px;
padding: 7px 9px;
font-family: "Cascadia Mono";
}
QTextEdit#CommandBox {
color: #3D4656;
background: #F7F8FB;
border: 1px solid #E4E7ED;
border-radius: 8px;
padding: 7px;
font-family: "Cascadia Mono";
font-size: 9px;
}
QLabel#LimitedBanner {
color: #805C1B;
background: #FFF7E7;
border-radius: 7px;
padding: 7px;
}
QLabel#EmptyIcon {
color: #6868D7;
background: #EEEEFF;
border-radius: 22px;
font-size: 23px;
font-weight: 700;
}
QLabel#EmptyTitle { color: #343A49; font-size: 14px; font-weight: 700; }
QProgressBar#LoadingBar {
min-height: 3px; max-height: 3px;
border: none; background: #EEEFF4;
}
QProgressBar#LoadingBar::chunk { background: #5B5BD6; }
QLabel#FooterStatus { color: #778092; font-size: 10px; }
QToolTip {
color: #FFFFFF;
background: #252A38;
border: none;
padding: 6px;
}
QMessageBox { background: #FFFFFF; }
"""

764
processdock/ui.py Normal file
View 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)