增加解除占用功能

This commit is contained in:
2026-04-08 00:07:40 +08:00
parent 005fc3a77c
commit ff8aa0269d
9 changed files with 823 additions and 8 deletions
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
import os
from typing import List
import psutil
from PyQt6.QtCore import Qt, pyqtSignal, QThread, QTimer
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QWidget,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
)
import ui.dialog_style as dialog_style
import ui.theme as theme
class _ScanWorker(QThread):
done = pyqtSignal(str, list)
error = pyqtSignal(str, str)
def __init__(self, file_path: str):
super().__init__()
self._file_path = file_path
def run(self):
try:
target = os.path.abspath(self._file_path)
found: list[dict] = []
if os.name == "nt":
try:
from ui.win_handle_scan import find_file_locks
locks = find_file_locks(target)
# 聚合:pid -> handles
mp: dict[int, list[int]] = {}
for lk in locks:
mp.setdefault(lk.pid, []).append(int(lk.handle))
for pid, handles in mp.items():
name = ""
try:
name = psutil.Process(pid).name()
except Exception:
name = f"PID {pid}"
found.append({"pid": pid, "name": name, "handles": handles})
except Exception:
found = []
# 兜底:psutil(可能漏报,但至少不会空白)
if not found:
target_norm = os.path.normcase(os.path.abspath(target))
for proc in psutil.process_iter(["pid", "name", "open_files"]):
try:
files = proc.info.get("open_files") or []
for f in files:
if os.path.normcase(f.path) == target_norm:
pid = int(proc.info.get("pid") or 0)
name = proc.info.get("name") or f"PID {pid}"
found.append({"pid": pid, "name": str(name), "handles": []})
break
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
self.done.emit(os.path.normcase(os.path.abspath(target)), found)
except Exception as e:
self.error.emit(self._file_path, str(e))
class _DropArea(QWidget):
file_dropped = pyqtSignal(str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setAcceptDrops(True)
self.setMinimumHeight(120)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event):
urls = event.mimeData().urls()
if not urls:
return
local = urls[0].toLocalFile()
if local:
self.file_dropped.emit(local)
def paintEvent(self, event):
from PyQt6.QtGui import QPainter, QPen, QBrush, QColor
super().paintEvent(event)
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.rect().adjusted(6, 6, -6, -6)
t = theme.current()
border = t.get("search_border", "#888")
bg = t.get("search_bg", "rgba(0,0,0,20)")
p.setBrush(QBrush(QColor(bg)))
pen = QPen(QColor(border))
pen.setStyle(Qt.PenStyle.DashLine)
pen.setWidth(1)
p.setPen(pen)
p.drawRoundedRect(rect, 8, 8)
p.end()
class UnlockDialog(QDialog):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("解除占用")
self.setMinimumSize(520, 420)
self._targets: List[psutil.Process] = []
self._file_path: str | None = None
self._scan_worker: _ScanWorker | None = None
self._loading_timer: QTimer | None = None
self._loading_step = 0
self._build_ui()
self._apply_theme()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 16, 18, 16)
layout.setSpacing(10)
intro = QLabel(
"将被占用的文件拖拽到下方虚线框内,我会尝试找出正在占用它的程序,"
"并在下方列出,方便你一键结束相关进程。"
)
intro.setWordWrap(True)
self._intro_lbl = intro
layout.addWidget(intro)
self._drop = _DropArea(self)
lbl = QLabel("拖拽文件到这里")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._drop_hint_lbl = lbl
layout_drop = QVBoxLayout(self._drop)
layout_drop.addStretch()
layout_drop.addWidget(lbl)
layout_drop.addStretch()
self._drop.file_dropped.connect(self._on_file_dropped)
layout.addWidget(self._drop)
self._path_lbl = QLabel("当前文件:未选择")
layout.addWidget(self._path_lbl)
self._list = QListWidget()
layout.addWidget(self._list, 1)
btn_row = QHBoxLayout()
btn_row.addStretch()
self._unlock_btn = QPushButton("解除占用(结束相关进程)")
self._unlock_btn.clicked.connect(self._on_unlock)
btn_close = QPushButton("关闭")
btn_close.clicked.connect(self.close)
btn_row.addWidget(self._unlock_btn)
btn_row.addWidget(btn_close)
layout.addLayout(btn_row)
self._close_btn = btn_close
def _apply_theme(self):
t = theme.current()
is_dark = theme.name() == "dark"
# 兼容 panel_bg 为 rgba 的情况
panel_bg = t.get("panel_bg", "rgba(30,30,30,240)")
if panel_bg.startswith("rgba"):
tmp = panel_bg.replace("rgba", "rgb")
head = tmp.rsplit(",", 1)[0]
panel_rgb = head + ")"
else:
panel_rgb = panel_bg
txt = t.get("search_color", "#eee")
sub = "#888" if is_dark else "#666"
border = t.get("panel_border", t.get("search_border", "#666"))
inp_bg = t.get("search_bg", "rgba(0,0,0,20)")
hover = t.get("header_hover", t.get("menu_selected", "rgba(128,128,128,40)"))
self.setStyleSheet(
f"""
QDialog, QWidget {{
background: {panel_rgb};
color: {txt};
}}
QListWidget {{
background: {inp_bg};
border: 1px solid {t.get('search_border', border)};
border-radius: 8px;
}}
QListWidget::item {{
padding: 6px 8px;
border-radius: 6px;
}}
QListWidget::item:selected {{
background: #4a9eff;
color: white;
}}
QListWidget::item:hover:!selected {{
background: {hover};
}}
QPushButton {{
background: {inp_bg};
color: {txt};
border: 1px solid {t.get('search_border', border)};
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
}}
QPushButton:hover {{
border-color: #4a9eff;
color: #4a9eff;
}}
"""
)
if hasattr(self, "_path_lbl"):
self._path_lbl.setStyleSheet(f"color:{sub}; font-size:11px; background:transparent;")
if hasattr(self, "_intro_lbl"):
self._intro_lbl.setStyleSheet("background:transparent;")
if hasattr(self, "_drop_hint_lbl"):
self._drop_hint_lbl.setStyleSheet(f"color:{sub}; background:transparent;")
def _on_file_dropped(self, path: str):
self._file_path = os.path.abspath(path)
self._path_lbl.setText(f"当前文件:{self._file_path}")
self._start_scan()
def _set_loading(self, loading: bool):
if loading:
self._unlock_btn.setEnabled(False)
self._list.clear()
self._list.addItem(QListWidgetItem("正在扫描占用进程…"))
if self._loading_timer is None:
self._loading_timer = QTimer(self)
self._loading_timer.setInterval(250)
self._loading_timer.timeout.connect(self._tick_loading)
self._loading_step = 0
self._loading_timer.start()
else:
self._unlock_btn.setEnabled(True)
if self._loading_timer is not None:
self._loading_timer.stop()
def _tick_loading(self):
# 简易“加载动画”:点点点
self._loading_step = (self._loading_step + 1) % 4
dots = "." * self._loading_step
txt = f"正在扫描占用进程{dots}"
if self._list.count() > 0:
it = self._list.item(0)
if it is not None:
it.setText(txt)
def _start_scan(self):
self._list.clear()
self._targets.clear()
if not self._file_path:
return
# 取消上一次扫描(如果仍在跑)
if self._scan_worker is not None and self._scan_worker.isRunning():
try:
self._scan_worker.requestInterruption()
except Exception:
pass
self._set_loading(True)
self._scan_worker = _ScanWorker(self._file_path)
self._scan_worker.done.connect(self._on_scan_done)
self._scan_worker.error.connect(self._on_scan_error)
self._scan_worker.start()
def _on_scan_error(self, path: str, err: str):
self._set_loading(False)
dialog_style.warning(self, "扫描失败", f"无法扫描占用进程:\n{err}")
def _on_scan_done(self, norm_target: str, found: list):
self._set_loading(False)
self._list.clear()
self._targets.clear()
if not found:
item = QListWidgetItem("未检测到占用该文件的进程。")
self._list.addItem(item)
return
self._lock_infos = found # type: ignore[attr-defined]
# 终止进程时使用
procs: list[psutil.Process] = []
for it in found:
try:
pid = int(it.get("pid") or 0)
if pid <= 0:
continue
procs.append(psutil.Process(pid))
except Exception:
continue
self._targets = procs
for it in found:
pid = int(it.get("pid") or 0)
name = str(it.get("name") or f"PID {pid}")
hcnt = len(it.get("handles") or [])
suffix = f" (句柄 {hcnt})" if hcnt > 0 else ""
txt = f"{name} (PID {pid}){suffix}"
self._list.addItem(QListWidgetItem(txt))
def _on_unlock(self):
if not self._targets:
dialog_style.information(self, "提示", "当前没有检测到需要解除的占用进程。")
return
# 优先:尝试关闭句柄(不杀进程)
closed_any = False
if os.name == "nt" and hasattr(self, "_lock_infos"):
try:
from ui.win_handle_scan import close_remote_handle
for it in getattr(self, "_lock_infos", []):
pid = int(it.get("pid") or 0)
for hv in it.get("handles") or []:
try:
if close_remote_handle(pid, int(hv)):
closed_any = True
except Exception:
continue
except Exception:
pass
if closed_any:
dialog_style.information(self, "已完成", "已尝试关闭相关文件句柄(不结束进程)。")
self._start_scan()
return
failed = []
for p in self._targets:
try:
p.terminate()
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
# 简单等待一会儿,再尝试强杀
for p in list(self._targets):
try:
p.wait(timeout=1.0)
except (psutil.TimeoutExpired, psutil.NoSuchProcess):
pass
for p in self._targets:
try:
if p.is_running():
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
failed.append(p)
except psutil.NoSuchProcess:
continue
if failed:
dialog_style.warning(
self,
"部分失败",
"部分进程无法结束,可能需要以管理员身份运行或手动关闭相关程序。",
)
else:
dialog_style.information(self, "已完成", "相关占用进程已尝试结束。")
self._start_scan()