4b0504ad by 柴进

fix(ui): caption 重叠问题 + 加截图工具

- caption (生成模式/宽高比/图片尺寸 等) 改用 make_field_group helper:
  组内 caption + control 间距锁死 6px,组高度 fixed = 22+6+34 = 62px
  解决 Qt QSS setStyleSheet 改字号不更新 sizeHint 导致的 caption 文字溢出到下方 widget 的坑
- caption 字号 11pt → 12pt,font-weight DemiBold,颜色 text_primary,视觉权重和 combo 拉开
- settings_layout 间距 18px(组与组之间)
- 主窗口最小尺寸 1180x820,避免压缩破坏 layout
- ImageGeneratorWindow 和 StyleDesignerTab 同步迁移
- capture_window.py:按窗口标题 PrintWindow 截图工具,迭代 UI 时不再依赖手动截图

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8938af9d
"""按窗口标题截图。
用法:
python capture_window.py <title-substring> [output.png]
例:
python capture_window.py 珠宝壹佰 shot.png
python capture_window.py 登录 login.png
依赖:Pillow(项目已装),ctypes(标准库)。
仅 Windows。
"""
import ctypes
import sys
from ctypes import wintypes
from pathlib import Path
from PIL import ImageGrab
user32 = ctypes.windll.user32
EnumWindowsProc = ctypes.WINFUNCTYPE(
ctypes.c_bool, wintypes.HWND, wintypes.LPARAM
)
def _get_window_text(hwnd):
length = user32.GetWindowTextLengthW(hwnd)
if length == 0:
return ""
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
return buf.value
_PROCESS_NAME_CACHE = {}
def _get_process_name(pid: int) -> str:
"""通过 pid 拿到进程可执行文件名(小写,不含路径)。"""
if pid in _PROCESS_NAME_CACHE:
return _PROCESS_NAME_CACHE[pid]
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not h:
_PROCESS_NAME_CACHE[pid] = ""
return ""
try:
buf = ctypes.create_unicode_buffer(260)
size = wintypes.DWORD(260)
if kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):
name = buf.value.split("\\")[-1].lower()
else:
name = ""
finally:
kernel32.CloseHandle(h)
_PROCESS_NAME_CACHE[pid] = name
return name
def find_window(title_substring: str, exe_filter: tuple = ("pythonw.exe", "python.exe", "zb100imagegenerator.exe")):
"""返回首个标题包含 title_substring 且属于 exe_filter 进程的可见窗口 hwnd。"""
found = []
def callback(hwnd, lparam):
if not user32.IsWindowVisible(hwnd):
return True
text = _get_window_text(hwnd)
if title_substring not in text:
return True
# 检查进程
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
name = _get_process_name(pid.value)
if name in exe_filter:
found.append((hwnd, text))
return False # 停止枚举
return True
user32.EnumWindows(EnumWindowsProc(callback), 0)
return found[0] if found else None
def get_window_bbox(hwnd):
"""获取窗口外接矩形(含标题栏 / 边框)。"""
rect = wintypes.RECT()
# DWMWA_EXTENDED_FRAME_BOUNDS = 9,能避开 Win10/11 的不可见 shadow border
DWMWA_EXTENDED_FRAME_BOUNDS = 9
dwmapi = ctypes.windll.dwmapi
res = dwmapi.DwmGetWindowAttribute(
wintypes.HWND(hwnd),
ctypes.c_uint(DWMWA_EXTENDED_FRAME_BOUNDS),
ctypes.byref(rect),
ctypes.sizeof(rect),
)
if res != 0:
# 回退到 GetWindowRect
user32.GetWindowRect(hwnd, ctypes.byref(rect))
return (rect.left, rect.top, rect.right, rect.bottom)
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
title = sys.argv[1]
output = Path(sys.argv[2] if len(sys.argv) > 2 else "shot.png").resolve()
hit = find_window(title)
if not hit:
print(f"找不到标题包含 '{title}' 的窗口", file=sys.stderr)
sys.exit(2)
hwnd, full_title = hit
# 用 PrintWindow 直接从窗口拿位图,不需要把窗口提前(避免打断用户)
bbox = get_window_bbox(hwnd)
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
img = _print_window_to_pil(hwnd, width, height)
if img is None:
# 回退:把窗口提前再截屏
import time
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
# 顶置-取消顶置技巧绕开 Windows 前台限制
HWND_TOPMOST = -1
HWND_NOTOPMOST = -2
SWP_NOMOVE = 0x0002
SWP_NOSIZE = 0x0001
user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
user32.SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE)
time.sleep(0.5)
img = ImageGrab.grab(bbox=bbox, all_screens=True)
img.save(output)
print(f"OK hwnd={hwnd} title={full_title!r} size={width}x{height} -> {output}")
def _print_window_to_pil(hwnd, width, height):
"""用 PrintWindow API 从窗口直接拿位图(不依赖窗口在前台)。"""
from PIL import Image
gdi32 = ctypes.windll.gdi32
hdcWindow = user32.GetDC(hwnd)
hdcMem = gdi32.CreateCompatibleDC(hdcWindow)
hbm = gdi32.CreateCompatibleBitmap(hdcWindow, width, height)
gdi32.SelectObject(hdcMem, hbm)
PW_RENDERFULLCONTENT = 0x00000002
ok = user32.PrintWindow(hwnd, hdcMem, PW_RENDERFULLCONTENT)
if ok:
# 提取位图数据
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [
("biSize", wintypes.DWORD),
("biWidth", wintypes.LONG),
("biHeight", wintypes.LONG),
("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD),
("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", wintypes.LONG),
("biYPelsPerMeter", wintypes.LONG),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
class BITMAPINFO(ctypes.Structure):
_fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wintypes.DWORD * 3)]
bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi.bmiHeader.biWidth = width
bmi.bmiHeader.biHeight = -height # top-down
bmi.bmiHeader.biPlanes = 1
bmi.bmiHeader.biBitCount = 32
bmi.bmiHeader.biCompression = 0 # BI_RGB
buf_len = width * height * 4
buf = (ctypes.c_ubyte * buf_len)()
gdi32.GetDIBits(hdcMem, hbm, 0, height, buf, ctypes.byref(bmi), 0)
img = Image.frombuffer("RGBA", (width, height), bytes(buf), "raw", "BGRA", 0, 1).convert("RGB")
else:
img = None
gdi32.DeleteObject(hbm)
gdi32.DeleteDC(hdcMem)
user32.ReleaseDC(hwnd, hdcWindow)
return img
if __name__ == "__main__":
main()
......@@ -1527,6 +1527,34 @@ class LoginDialog(QDialog):
THUMB_REORDER_MIME = "application/x-zb100-thumb-index"
def make_caption_label(text: str) -> QLabel:
"""构造 caption 风格 label(设置区上方的小标签)。"""
lbl = QLabel(text)
lbl.setProperty("role", "caption")
f = QFont()
f.setPointSize(12)
f.setWeight(QFont.Weight.DemiBold)
lbl.setFont(f)
lbl.setFixedHeight(22)
return lbl
def make_field_group(caption_text: str, control: QWidget) -> QWidget:
"""把一个 caption + 控件打包成独立 widget,组内间距锁死 6px。
每组高度 = 22 (caption) + 6 (spacing) + 34 (control) = 62px,硬锁。
"""
# 锁住 control 高度(FixedHeight 让 sizeHint = 34,layout 不能压缩)
control.setFixedHeight(34)
w = QWidget()
w.setFixedHeight(22 + 6 + 34) # 锁死整个组高度
layout = QVBoxLayout(w)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
layout.addWidget(make_caption_label(caption_text))
layout.addWidget(control)
return w
class DraggableThumbnail(QWidget):
"""可拖拽重排序的缩略图容器"""
......@@ -1944,8 +1972,8 @@ class ImageGeneratorWindow(QMainWindow):
def setup_ui(self):
"""Setup the user interface"""
self.setWindowTitle("珠宝壹佰图像生成器")
self.setGeometry(100, 100, 1200, 850)
self.setMinimumSize(1000, 700)
self.setGeometry(100, 100, 1280, 880)
self.setMinimumSize(1180, 820)
# Central widget
central_widget = QWidget()
......@@ -2096,19 +2124,16 @@ class ImageGeneratorWindow(QMainWindow):
# Settings section
settings_group = QGroupBox("生成设置")
settings_layout = QVBoxLayout()
settings_layout.setSpacing(18)
# 生成模式(放在最前面)
settings_layout.addWidget(QLabel("生成模式"))
self.generation_mode = QComboBox()
self.generation_mode.addItems(["极速模式", "慢速模式"])
self.generation_mode.setCurrentIndex(0) # Default to 极速模式
self.generation_mode.currentIndexChanged.connect(self.on_generation_mode_changed)
settings_layout.addWidget(self.generation_mode)
settings_layout.addSpacing(10)
settings_layout.addWidget(make_field_group("生成模式", self.generation_mode))
# 宽高比
settings_layout.addWidget(QLabel("宽高比"))
self.aspect_ratio = QComboBox()
self.aspect_ratio.addItems([
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
......@@ -2118,19 +2143,14 @@ class ImageGeneratorWindow(QMainWindow):
# 记录上一次值用于用户拒绝切换模式时回滚,避免留在一个无法提交的状态
self._prev_aspect_ratio = self.aspect_ratio.currentText()
self.aspect_ratio.currentTextChanged.connect(self._on_aspect_ratio_changed)
settings_layout.addWidget(self.aspect_ratio)
settings_layout.addSpacing(10)
settings_layout.addWidget(make_field_group("宽高比", self.aspect_ratio))
# 图片尺寸
settings_layout.addWidget(QLabel("图片尺寸"))
self.image_size = QComboBox()
self.image_size.addItems(["1K", "2K", "4K"])
self.image_size.setCurrentIndex(0) # Default to 1K for 极速模式
self.image_size.currentIndexChanged.connect(self.on_image_size_changed)
settings_layout.addWidget(self.image_size)
settings_layout.addSpacing(10)
settings_layout.addWidget(make_field_group("图片尺寸", self.image_size))
settings_layout.addStretch()
settings_group.setLayout(settings_layout)
......@@ -4085,23 +4105,16 @@ class StyleDesignerTab(QWidget):
# Settings section
settings_group = QGroupBox("生成设置")
settings_layout = QVBoxLayout()
settings_layout.setSpacing(18)
# 生成模式(放在最前面)
mode_label = QLabel("生成模式")
mode_label.setProperty("role", "caption")
settings_layout.addWidget(mode_label)
self.generation_mode = QComboBox()
self.generation_mode.addItems(["极速模式", "慢速模式"])
self.generation_mode.setCurrentIndex(0) # Default to 极速模式
self.generation_mode.currentIndexChanged.connect(self.on_generation_mode_changed)
settings_layout.addWidget(self.generation_mode)
settings_layout.addSpacing(10)
settings_layout.addWidget(make_field_group("生成模式", self.generation_mode))
# 宽高比
aspect_label = QLabel("宽高比")
aspect_label.setProperty("role", "caption")
settings_layout.addWidget(aspect_label)
self.aspect_ratio = QComboBox()
self.aspect_ratio.addItems([
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
......@@ -4111,21 +4124,14 @@ class StyleDesignerTab(QWidget):
# 记录上一次值用于用户拒绝切换模式时回滚
self._prev_aspect_ratio = self.aspect_ratio.currentText()
self.aspect_ratio.currentTextChanged.connect(self._on_aspect_ratio_changed)
settings_layout.addWidget(self.aspect_ratio)
settings_layout.addSpacing(10)
settings_layout.addWidget(make_field_group("宽高比", self.aspect_ratio))
# 图片尺寸
size_label = QLabel("图片尺寸")
size_label.setProperty("role", "caption")
settings_layout.addWidget(size_label)
self.image_size = QComboBox()
self.image_size.addItems(["1K", "2K", "4K"])
self.image_size.setCurrentIndex(0) # Default to 1K for 极速模式
self.image_size.currentIndexChanged.connect(self.on_image_size_changed)
settings_layout.addWidget(self.image_size)
settings_layout.addSpacing(10)
settings_layout.addWidget(make_field_group("图片尺寸", self.image_size))
settings_layout.addStretch()
settings_group.setLayout(settings_layout)
......@@ -4218,22 +4224,25 @@ class StyleDesignerTab(QWidget):
# 添加按钮(使用表情符号)
add_btn = QPushButton("➕")
add_btn.setProperty("size", "icon")
add_btn.clicked.connect(lambda: self.add_library_item(category))
add_btn.setFixedWidth(40)
add_btn.setFixedWidth(36)
add_btn.setToolTip("添加词库项")
layout.addWidget(add_btn)
# 删除按钮(使用表情符号)
del_btn = QPushButton("🗑️")
del_btn.setProperty("size", "icon")
del_btn.clicked.connect(lambda: self.remove_library_item(category))
del_btn.setFixedWidth(40)
del_btn.setFixedWidth(36)
del_btn.setToolTip("删除词库项")
layout.addWidget(del_btn)
# 锁定/解锁按钮(使用表情符号)
lock_btn = QPushButton("🔓")
lock_btn.setProperty("size", "icon")
lock_btn.clicked.connect(lambda: self.toggle_field_lock(category))
lock_btn.setFixedWidth(40)
lock_btn.setFixedWidth(36)
lock_btn.setToolTip("锁定/解锁字段")
self.lock_buttons[category] = lock_btn
layout.addWidget(lock_btn)
......
......@@ -167,10 +167,9 @@ QLabel[role="muted"] {{
font-size: {s['font_sm']};
}}
QLabel[role="caption"] {{
color: {c['text_secondary']};
font-size: {s['font_xs']};
text-transform: uppercase;
letter-spacing: 1px;
color: {c['text_primary']};
font-size: {s['font_base']};
font-weight: 600;
}}
QLabel[role="title"] {{
color: {c['text_primary']};
......@@ -190,8 +189,8 @@ QGroupBox {{
background-color: {c['bg_surface']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
margin-top: 14px;
padding: {s['space_4']} {s['space_3']} {s['space_3']} {s['space_3']};
margin-top: 18px;
padding: 20px 14px 14px 14px;
font-weight: 600;
font-size: {s['font_sm']};
color: {c['text_secondary']};
......@@ -203,9 +202,8 @@ QGroupBox::title {{
padding: 0 {s['space_2']};
color: {c['text_secondary']};
background: transparent;
text-transform: uppercase;
letter-spacing: 1px;
font-size: {s['font_xs']};
font-size: {s['font_sm']};
font-weight: 600;
}}
/* ========== 按钮 ========== */
......@@ -672,6 +670,14 @@ QPushButton#thumbDeleteBtn {{
QPushButton#thumbDeleteBtn:hover {{
background-color: {c['danger_hover']};
}}
/* ========== 紧凑图标按钮(款式设计 tab 的 ➕🗑🔓 等)========== */
QPushButton[size="icon"] {{
padding: 0;
min-width: 30px;
min-height: 30px;
font-size: {s['font_base']};
}}
"""
......