[Python] 纯文本查看 复制代码
# -*- coding: utf-8 -*-
"""
话术管理助手 - 单文件版
"""
import tkinter as tk
from tkinter import ttk, messagebox, font
import pyperclip
import json
import os
import threading
import time
import shutil
from typing import Dict, List, Any, Optional, Callable, Tuple
from dataclasses import dataclass, asdict, field
# ==================== 常量 ====================
WINDOW_TITLE = "话术管理"
WINDOW_GEOMETRY = "1100x700"
ICON_FILE = "icon.ico"
DEFAULT_ALWAYS_ON_TOP = False
DEFAULT_SHOW_SEARCH = True
SEARCH_PLACEHOLDER = "全局搜索内容..."
DEFAULT_FONT_FAMILY = "Consolas"
DEFAULT_FONT_SIZE = 10
DEFAULT_FONT_COLOR = "黑色"
DEFAULT_BG_COLOR = "白色"
DEFAULT_UI_FONT = "微软雅黑"
DISPLAY_MAX_LEN = 80
SAVE_DELAY = 1.0
SEARCH_DEBOUNCE_MS = 300
INPUT_DIALOG_SIZE = (400, 180)
TEXT_DIALOG_SIZE = (500, 350)
TEXT_DIALOG_MIN_SIZE = (450, 300)
BACKUP_SUFFIX = ".backup"
# ==================== UI文本 ====================
BTN_OK = "确定"
BTN_CANCEL = "取消"
BTN_ADD_GROUP = "添加分组"
BTN_RENAME_GROUP = "重命名分组"
BTN_DELETE_GROUP = "删除分组"
BTN_ADD_SCRIPT = "添加话术"
BTN_EDIT_SCRIPT = "编辑话术"
BTN_DELETE_SCRIPT = "删除话术"
MENU_ADD_GROUP = "添加分组"
MENU_RENAME_GROUP = "重命名分组"
MENU_DELETE_GROUP = "删除分组"
MENU_ADD_SCRIPT = "添加话术"
MENU_EDIT_SCRIPT = "编辑话术"
MENU_DELETE_SCRIPT = "删除话术"
DLG_TITLE_ADD_GROUP = "新建分组"
DLG_TITLE_RENAME_GROUP = "重命名分组"
DLG_TITLE_ADD_SCRIPT = "添加话术"
DLG_TITLE_EDIT_SCRIPT = "编辑话术"
DLG_PROMPT_GROUP_NAME = "请输入分组名称:"
DLG_PROMPT_NEW_GROUP_NAME = "请输入新分组名称:"
DLG_PROMPT_SCRIPT_CONTENT = "请输入话术内容:"
DLG_PROMPT_EDIT_SCRIPT = "请编辑话术内容:"
WARNING_TITLE = "警告"
CONFIRM_TITLE = "确认"
INFO_TITLE = "提示"
WARN_GROUP_NAME_EMPTY = "分组名称不能为空!"
WARN_GROUP_NAME_EXISTS = "分组名称已存在!"
WARN_GROUP_NOT_SELECTED = "请先选择一个分组!"
WARN_SCRIPT_CONTENT_EMPTY = "话术内容不能为空!"
WARN_SCRIPT_NOT_SELECTED = "请先选择一个话术!"
WARN_AT_LEAST_ONE_GROUP = "至少需要保留一个分组!"
WARN_GROUP_NOT_FOUND = "分组不存在!"
WARN_SCRIPT_INDEX_ERROR = "话术索引错误!"
WARN_LOAD_DATA_FAILED = "加载数据失败,已使用备份或创建默认数据。"
CONFIRM_DELETE_GROUP = "确定要删除分组 '{group_name}' 吗?"
CONFIRM_DELETE_SCRIPT = "确定要删除选中的话术吗?"
TOOLTIP_FONT_SIZE = "字体大小"
TOOLTIP_FONT_COLOR = "字体颜色"
TOOLTIP_BG_COLOR = "背景颜色"
TOOLTIP_ALWAYS_ON_TOP = "窗口置顶"
DEFAULT_GROUP_NAME = "默认分组"
TOGGLE_BTN_HIDE = "▶"
TOGGLE_BTN_SHOW = "◀"
COPY_SUCCESS = "已复制到剪贴板"
# ==================== 颜色映射 ====================
COLOR_MAP = {
"黑色": "black", "白色": "white", "红色": "red", "绿色": "green",
"蓝色": "blue", "黄色": "yellow", "紫色": "purple", "橙色": "orange",
"粉色": "pink", "棕色": "brown", "青色": "cyan", "洋红色": "magenta",
"深红色": "darkred", "深绿色": "darkgreen", "深蓝色": "darkblue",
"深紫色": "darkviolet", "深橙色": "darkorange", "浅灰色": "lightgray",
"浅黄色": "lightyellow", "浅蓝色": "lightblue", "浅绿色": "lightgreen",
"浅粉色": "lightpink", "浅紫色": "lavender", "灰色": "gray",
"深灰色": "darkgray", "银灰色": "silver", "金色": "gold", "海军蓝": "navy",
"橄榄色": "olive", "绿松石": "turquoise", "珊瑚色": "coral",
"紫罗兰": "violet", "米色": "beige", "象牙色": "ivory",
"薄荷色": "mintcream", "天蓝色": "skyblue", "巧克力色": "chocolate",
"番茄色": "tomato"
}
SHORT_TO_FULL_COLOR = {
"黑": "黑色", "红": "红色", "绿": "绿色", "蓝": "蓝色",
"紫": "紫色", "橙": "橙色", "棕": "棕色", "灰": "灰色"
}
SHORT_TO_FULL_BG = {
"白": "白色", "浅灰": "浅灰色", "浅黄": "浅黄色", "浅蓝": "浅蓝色",
"浅绿": "浅绿色", "浅粉": "浅粉色", "米色": "米色", "象牙": "象牙色"
}
FULL_TO_SHORT_COLOR = {v: k for k, v in SHORT_TO_FULL_COLOR.items()}
FULL_TO_SHORT_BG = {v: k for k, v in SHORT_TO_FULL_BG.items()}
FONT_COLOR_OPTIONS = list(SHORT_TO_FULL_COLOR.keys())
BG_COLOR_OPTIONS = list(SHORT_TO_FULL_BG.keys())
# ==================== 数据模型 ====================
@dataclass
class ScriptItem:
content: str
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ScriptItem':
return cls(**data)
@dataclass
class Group:
name: str
script_items: List[ScriptItem] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"scripts": [item.to_dict() for item in self.script_items]
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Group':
scripts_data = data.get("scripts", [])
if scripts_data and isinstance(scripts_data[0], str):
script_items = [ScriptItem(content=content) for content in scripts_data]
else:
script_items = [ScriptItem.from_dict(item) for item in scripts_data]
return cls(name=data["name"], script_items=script_items)
# ==================== 管理器 ====================
class ConfigManager:
def __init__(self, config_file: str = "settings.json"):
self.config_file = config_file
self._cache = {}
self._load_config()
def _load_config(self):
default_config = {
"window_title": WINDOW_TITLE,
"window_geometry": WINDOW_GEOMETRY,
"list_font_size": DEFAULT_FONT_SIZE,
"list_font_family": DEFAULT_FONT_FAMILY,
"list_font_color": DEFAULT_FONT_COLOR,
"list_bg_color": DEFAULT_BG_COLOR,
"always_on_top": DEFAULT_ALWAYS_ON_TOP,
"show_search": DEFAULT_SHOW_SEARCH,
"group_panel_width": 150
}
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
loaded = json.load(f)
self._cache = {**default_config, **loaded}
except Exception:
self._cache = default_config
else:
self._cache = default_config
def get(self, key: str, default: Any = None) -> Any:
return self._cache.get(key, default)
def set(self, key: str, value: Any, save: bool = True):
self._cache[key] = value
if save:
self.save()
def save(self):
try:
temp_file = self.config_file + ".tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(self._cache, f, ensure_ascii=False, indent=2)
os.replace(temp_file, self.config_file)
except Exception as e:
print(f"保存配置失败: {e}")
class DataStorage:
def __init__(self, data_file: str = "groups.json"):
self.data_file = data_file
self._groups: List[Group] = []
self._cache: Dict[str, Group] = {}
self._dirty = False
self._lock = threading.RLock()
self._save_timer = None
self.load()
def load(self) -> bool:
with self._lock:
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._groups = [Group.from_dict(item) for item in data]
self._build_cache()
return True
except Exception as e:
print(f"加载数据失败: {e}")
backup_file = self.data_file + BACKUP_SUFFIX
if os.path.exists(backup_file):
try:
with open(backup_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._groups = [Group.from_dict(item) for item in data]
self._build_cache()
print("已从备份恢复数据")
return True
except Exception:
print("备份恢复也失败")
self._create_default_data()
return False
else:
self._create_default_data()
return True
def _create_default_data(self):
self._groups = [Group(name=DEFAULT_GROUP_NAME)]
self._build_cache()
def _build_cache(self):
self._cache = {group.name: group for group in self._groups}
def _schedule_save(self):
if self._save_timer is not None:
self._save_timer.cancel()
self._save_timer = None
self._save_timer = threading.Timer(SAVE_DELAY, self._save_if_dirty)
self._save_timer.daemon = True
self._save_timer.start()
def _save_if_dirty(self):
if self._dirty:
self.save(async_mode=False)
def save(self, async_mode: bool = True):
def _do_save():
with self._lock:
if not self._dirty:
return
try:
if os.path.exists(self.data_file):
backup = self.data_file + BACKUP_SUFFIX
shutil.copy2(self.data_file, backup)
data = [group.to_dict() for group in self._groups]
temp_file = self.data_file + ".tmp"
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(temp_file, self.data_file)
self._dirty = False
except Exception as e:
print(f"保存数据失败: {e}")
if async_mode:
thread = threading.Thread(target=_do_save, daemon=True)
thread.start()
else:
_do_save()
def mark_dirty(self):
with self._lock:
self._dirty = True
self._schedule_save()
# ---------- 分组操作 ----------
def get_groups(self) -> List[Group]:
with self._lock:
return self._groups.copy()
def get_group(self, name: str) -> Optional[Group]:
with self._lock:
return self._cache.get(name)
def add_group(self, name: str) -> bool:
with self._lock:
if name in self._cache:
return False
new_group = Group(name=name)
self._groups.append(new_group)
self._cache[name] = new_group
self.mark_dirty()
return True
def rename_group(self, old_name: str, new_name: str) -> bool:
with self._lock:
if new_name in self._cache or old_name not in self._cache:
return False
group = self._cache[old_name]
group.name = new_name
del self._cache[old_name]
self._cache[new_name] = group
self.mark_dirty()
return True
def delete_group(self, name: str) -> bool:
with self._lock:
if name not in self._cache or len(self._groups) <= 1:
return False
self._groups = [g for g in self._groups if g.name != name]
del self._cache[name]
self.mark_dirty()
return True
# ---------- 话术操作 ----------
def add_script(self, group_name: str, content: str) -> bool:
with self._lock:
group = self._cache.get(group_name)
if not group:
return False
group.script_items.append(ScriptItem(content=content))
self.mark_dirty()
return True
def update_script(self, group_name: str, index: int, content: str) -> bool:
with self._lock:
group = self._cache.get(group_name)
if not group or index >= len(group.script_items):
return False
group.script_items[index].content = content
group.script_items[index].updated_at = time.time()
self.mark_dirty()
return True
def delete_script(self, group_name: str, index: int) -> bool:
with self._lock:
group = self._cache.get(group_name)
if not group or index >= len(group.script_items):
return False
group.script_items.pop(index)
self.mark_dirty()
return True
def get_script_content(self, group_name: str) -> List[str]:
with self._lock:
group = self._cache.get(group_name)
return [item.content for item in group.script_items] if group else []
def search_script(self, query: str) -> Dict[str, List[str]]:
results = {}
query = query.lower()
with self._lock:
for group in self._groups:
matches = [item.content for item in group.script_items if query in item.content.lower()]
if matches:
results[group.name] = matches
return results
class ColorManager:
FULL_TO_SHORT_COLOR = FULL_TO_SHORT_COLOR
FULL_TO_SHORT_BG = FULL_TO_SHORT_BG
SHORT_TO_FULL_COLOR = SHORT_TO_FULL_COLOR
SHORT_TO_FULL_BG = SHORT_TO_FULL_BG
@classmethod
def get_full_color(cls, short_name: str, is_background: bool = False) -> str:
if is_background:
return cls.SHORT_TO_FULL_BG.get(short_name, "白色")
return cls.SHORT_TO_FULL_COLOR.get(short_name, "黑色")
@classmethod
def get_short_color(cls, full_name: str, is_background: bool = False) -> str:
if is_background:
return cls.FULL_TO_SHORT_BG.get(full_name, "白")
return cls.FULL_TO_SHORT_COLOR.get(full_name, "黑")
@classmethod
def get_tk_color(cls, color_name: str) -> str:
return COLOR_MAP.get(color_name, "black")
@classmethod
def get_font_color_options(cls) -> List[str]:
return FONT_COLOR_OPTIONS
@classmethod
def get_bg_color_options(cls) -> List[str]:
return BG_COLOR_OPTIONS
# ==================== 自定义组件 ====================
class WidgetFactory:
@staticmethod
def create_tooltip(parent, widget, text: str, delay: int = 2000):
def show_tooltip(event):
tooltip = tk.Toplevel(parent)
tooltip.wm_overrideredirect(True)
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
label = tk.Label(tooltip, text=text, background="#ffffe0",
relief="solid", borderwidth=1, font=("微软雅黑", 9))
label.pack()
widget.tooltip_window = tooltip
def hide_tooltip():
if hasattr(widget, 'tooltip_window'):
widget.tooltip_window.destroy()
delattr(widget, 'tooltip_window')
widget.bind("<Leave>", lambda e: hide_tooltip())
widget.tooltip_id = parent.after(delay, hide_tooltip)
widget.bind("<Enter>", show_tooltip)
class DraggableListbox(tk.Listbox):
def __init__(self, parent, on_swap: Callable[[int, int], None],
drag_enabled: bool = True, **kwargs):
super().__init__(parent, **kwargs)
self.on_swap = on_swap
self.drag_enabled = drag_enabled
self.drag_start_index: Optional[int] = None
self._last_swap_time = 0
if self.drag_enabled:
self.bind("<ButtonPress-1>", self._start_drag)
self.bind("<B1-Motion>", self._do_drag)
self.bind("<ButtonRelease-1>", self._stop_drag)
def _get_valid_index(self, y: int) -> Optional[int]:
index = self.nearest(y)
bbox = self.bbox(index)
if bbox and bbox[1] <= y <= bbox[1] + bbox[3]:
return index
return None
def _start_drag(self, event):
index = self._get_valid_index(event.y)
if index is not None:
self.drag_start_index = index
self.config(cursor="fleur")
def _do_drag(self, event):
if self.drag_start_index is None:
return
current_index = self.nearest(event.y)
if current_index != self.drag_start_index:
now = time.time()
if now - self._last_swap_time < 0.05:
return
self._last_swap_time = now
self.on_swap(self.drag_start_index, current_index)
self.drag_start_index = current_index
def _stop_drag(self, event):
if self.drag_start_index is not None:
self.config(cursor="")
self.drag_start_index = None
self._last_swap_time = 0
# ==================== 对话框 ====================
class InputDialog:
def __init__(self, parent, title: str, prompt: str,
default_text: str = "", callback: Optional[Callable] = None):
self.parent = parent
self.callback = callback
self.dialog = tk.Toplevel(parent)
self.dialog.title(title)
w, h = INPUT_DIALOG_SIZE
self.dialog.geometry(f"{w}x{h}")
self.dialog.resizable(False, False)
self.dialog.transient(parent)
self.dialog.grab_set()
self._center()
self._build_ui(prompt, default_text)
def _center(self):
self.dialog.update_idletasks()
x = self.parent.winfo_rootx() + (self.parent.winfo_width() - self.dialog.winfo_width()) // 2
y = self.parent.winfo_rooty() + (self.parent.winfo_height() - self.dialog.winfo_height()) // 2
self.dialog.geometry(f"+{x}+{y}")
def _build_ui(self, prompt: str, default_text: str):
main_frame = ttk.Frame(self.dialog)
main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
main_frame.grid_rowconfigure(1, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
ttk.Label(main_frame, text=prompt, font=(DEFAULT_UI_FONT, 10)) \
.grid(row=0, column=0, sticky="w", pady=(0, 10))
self.entry_var = tk.StringVar(value=default_text)
entry = ttk.Entry(main_frame, textvariable=self.entry_var, font=(DEFAULT_UI_FONT, 10))
entry.grid(row=1, column=0, sticky="ew", pady=(0, 20))
entry.focus_set()
if default_text:
entry.select_range(0, tk.END)
btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=2, column=0, sticky="e")
def on_ok():
value = self.entry_var.get().strip()
if value and self.callback:
self.callback(value)
self.dialog.destroy()
def on_cancel():
self.dialog.destroy()
ttk.Button(btn_frame, text=BTN_OK, command=on_ok, width=10) \
.pack(side=tk.LEFT, padx=(0, 10))
ttk.Button(btn_frame, text=BTN_CANCEL, command=on_cancel, width=10) \
.pack(side=tk.LEFT)
self.dialog.bind("<Return>", lambda e: on_ok())
self.dialog.bind("<Escape>", lambda e: on_cancel())
class TextDialog:
def __init__(self, parent, title: str, prompt: str,
content: str = "", callback: Optional[Callable] = None):
self.parent = parent
self.callback = callback
self.dialog = tk.Toplevel(parent)
self.dialog.title(title)
w, h = TEXT_DIALOG_SIZE
self.dialog.geometry(f"{w}x{h}")
min_w, min_h = TEXT_DIALOG_MIN_SIZE
self.dialog.minsize(min_w, min_h)
self.dialog.transient(parent)
self.dialog.grab_set()
self._center()
self._build_ui(prompt, content)
def _center(self):
self.dialog.update_idletasks()
x = self.parent.winfo_rootx() + (self.parent.winfo_width() - self.dialog.winfo_width()) // 2
y = self.parent.winfo_rooty() + (self.parent.winfo_height() - self.dialog.winfo_height()) // 2
self.dialog.geometry(f"+{x}+{y}")
def _build_ui(self, prompt: str, content: str):
main_frame = ttk.Frame(self.dialog)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
main_frame.grid_rowconfigure(0, weight=0)
main_frame.grid_rowconfigure(1, weight=1)
main_frame.grid_rowconfigure(2, weight=0)
main_frame.grid_columnconfigure(0, weight=1)
ttk.Label(main_frame, text=prompt, font=(DEFAULT_UI_FONT, 10)) \
.grid(row=0, column=0, sticky="w", pady=(0, 5))
text_container = ttk.Frame(main_frame)
text_container.grid(row=1, column=0, sticky="nsew", pady=(0, 10))
text_container.grid_rowconfigure(0, weight=1)
text_container.grid_columnconfigure(0, weight=1)
self.text_widget = tk.Text(text_container, font=("Consolas", 10), wrap=tk.WORD)
self.text_widget.grid(row=0, column=0, sticky="nsew")
if content:
self.text_widget.insert("1.0", content)
self.text_widget.tag_add(tk.SEL, "1.0", tk.END)
self.text_widget.mark_set(tk.INSERT, "1.0")
self.text_widget.see(tk.INSERT)
scrollbar = ttk.Scrollbar(text_container, command=self.text_widget.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.text_widget.config(yscrollcommand=scrollbar.set)
btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=2, column=0, sticky="e", pady=(5, 0))
def on_ok():
value = self.text_widget.get("1.0", tk.END).strip()
if value and self.callback:
self.callback(value)
self.dialog.destroy()
def on_cancel():
self.dialog.destroy()
ttk.Button(btn_frame, text=BTN_OK, command=on_ok, width=8) \
.pack(side=tk.LEFT, padx=(0, 10))
ttk.Button(btn_frame, text=BTN_CANCEL, command=on_cancel, width=8) \
.pack(side=tk.LEFT)
self.dialog.bind("<Escape>", lambda e: on_cancel())
self.text_widget.bind("<Control-Return>", lambda e: on_ok())
self.text_widget.focus_set()
# ==================== 分组面板 ====================
class GroupPanel:
def __init__(self, parent, storage: DataStorage, on_group_selected: Callable):
self.storage = storage
self.on_group_selected = on_group_selected
self.current_group: Optional[str] = None
self.frame = tk.Frame(parent, relief="flat", borderwidth=0, bg="white")
self.listbox = DraggableListbox(
self.frame,
on_swap=self._swap_groups,
font=(DEFAULT_UI_FONT, 10),
selectmode=tk.SINGLE,
relief="flat",
borderwidth=0,
highlightthickness=0,
drag_enabled=True
)
self.listbox.pack(fill=tk.BOTH, expand=True)
scrollbar = tk.Scrollbar(self.frame, command=self.listbox.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.listbox.config(yscrollcommand=scrollbar.set)
self.menu = tk.Menu(self.frame, tearoff=0)
self.menu.add_command(label=MENU_ADD_GROUP, command=self._add_group_dialog)
self.menu.add_separator()
self.menu.add_command(label=MENU_RENAME_GROUP, command=self._rename_group_dialog)
self.menu.add_command(label=MENU_DELETE_GROUP, command=self._delete_group_dialog)
self.listbox.bind("<<ListboxSelect>>", self._on_select)
self.listbox.bind("<Button-3>", self._show_menu)
self.frame.config(bg=self.listbox.cget("bg"))
def refresh(self):
self.listbox.delete(0, tk.END)
for group in self.storage.get_groups():
self.listbox.insert(tk.END, group.name)
if self.current_group:
for i in range(self.listbox.size()):
if self.listbox.get(i) == self.current_group:
self.listbox.selection_set(i)
break
def set_current_group(self, name: str):
self.current_group = name
self.refresh()
def get_current_group(self) -> Optional[str]:
return self.current_group
def refresh_appearance(self, bg_color: str):
self.listbox.config(background=bg_color)
self.frame.config(bg=bg_color)
def _on_select(self, event):
selection = self.listbox.curselection()
if selection:
self.current_group = self.listbox.get(selection[0])
self.on_group_selected(self.current_group)
def _swap_groups(self, idx1: int, idx2: int):
groups = self.storage.get_groups()
if 0 <= idx1 < len(groups) and 0 <= idx2 < len(groups):
groups[idx1], groups[idx2] = groups[idx2], groups[idx1]
self.storage.mark_dirty()
item1 = self.listbox.get(idx1)
item2 = self.listbox.get(idx2)
self.listbox.delete(idx1)
self.listbox.insert(idx1, item2)
self.listbox.delete(idx2)
self.listbox.insert(idx2, item1)
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(idx2)
self.current_group = groups[idx2].name
def _add_group_dialog(self):
def add_group(name: str):
if not name:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NAME_EMPTY)
return
if self.storage.add_group(name):
self.refresh()
self.current_group = name
self.on_group_selected(name)
else:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NAME_EXISTS)
InputDialog(self.frame.winfo_toplevel(), DLG_TITLE_ADD_GROUP,
DLG_PROMPT_GROUP_NAME, "", add_group)
def _rename_group_dialog(self):
selection = self.listbox.curselection()
if not selection:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NOT_SELECTED)
return
old_name = self.listbox.get(selection[0])
def rename_group(new_name: str):
if not new_name:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NAME_EMPTY)
return
if old_name == new_name:
return
if self.storage.rename_group(old_name, new_name):
if self.current_group == old_name:
self.current_group = new_name
self.refresh()
self.on_group_selected(self.current_group)
else:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NAME_EXISTS)
InputDialog(self.frame.winfo_toplevel(), DLG_TITLE_RENAME_GROUP,
DLG_PROMPT_NEW_GROUP_NAME, old_name, rename_group)
def _delete_group_dialog(self):
selection = self.listbox.curselection()
if not selection:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NOT_SELECTED)
return
group_name = self.listbox.get(selection[0])
if messagebox.askyesno(CONFIRM_TITLE, CONFIRM_DELETE_GROUP.format(group_name=group_name)):
if self.storage.delete_group(group_name):
groups = self.storage.get_groups()
new_current = groups[0].name if groups else None
self.current_group = new_current
self.refresh()
self.on_group_selected(new_current)
else:
messagebox.showwarning(WARNING_TITLE, WARN_AT_LEAST_ONE_GROUP)
def _show_menu(self, event):
try:
index = self.listbox.nearest(event.y)
bbox = self.listbox.bbox(index)
if bbox and bbox[1] <= event.y <= bbox[1] + bbox[3]:
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(index)
self.listbox.activate(index)
self.menu.tk_popup(event.x_root, event.y_root)
finally:
self.menu.grab_release()
# ==================== 话术面板 ====================
class ScriptPanel:
def __init__(self, parent, storage: DataStorage, config: ConfigManager):
self.storage = storage
self.config = config
self.color_mgr = ColorManager()
self.current_group: Optional[str] = None
self.frame = tk.Frame(parent, relief="flat", borderwidth=0, bg="white")
self.list_font = font.Font(
family=self.config.get("list_font_family", DEFAULT_FONT_FAMILY),
size=self.config.get("list_font_size", DEFAULT_FONT_SIZE)
)
self._cached_scripts = []
self._cached_display = []
self._current_filter = ""
self._display_to_cache = []
self.listbox = DraggableListbox(
self.frame,
on_swap=self._swap_scripts,
font=self.list_font,
selectmode=tk.SINGLE,
relief="flat",
borderwidth=0,
highlightthickness=0,
drag_enabled=True
)
self.listbox.pack(fill=tk.BOTH, expand=True)
scrollbar = tk.Scrollbar(self.frame, command=self.listbox.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.listbox.config(yscrollcommand=scrollbar.set)
self.menu = tk.Menu(self.frame, tearoff=0)
self.menu.add_command(label=MENU_ADD_SCRIPT, command=self._add_script_dialog)
self.menu.add_separator()
self.menu.add_command(label=MENU_EDIT_SCRIPT, command=self._edit_script)
self.menu.add_command(label=MENU_DELETE_SCRIPT, command=self._delete_script)
self.listbox.bind("<<ListboxSelect>>", self._on_select)
self.listbox.bind("<Double-Button-1>", self._use_script)
self.listbox.bind("<Button-3>", self._show_menu)
self._search_after_id = None
self._apply_appearance()
self.frame.config(bg=self.listbox.cget("bg"))
def _format_display(self, content: str, index: int) -> str:
display = content.replace('\n', ' ↵ ')
display = f"{index}. {display}"
if len(display) > DISPLAY_MAX_LEN:
display = display[:DISPLAY_MAX_LEN-3] + "..."
return display
def _load_cache(self):
if self.current_group:
raw = self.storage.get_script_content(self.current_group)
self._cached_scripts = raw
self._cached_display = []
for i, content in enumerate(raw, 1):
self._cached_display.append(self._format_display(content, i))
else:
self._cached_scripts = []
self._cached_display = []
def set_group(self, group_name: str):
self.current_group = group_name
self._load_cache()
self.refresh()
def refresh(self, filter_keyword: str = ""):
self.listbox.delete(0, tk.END)
if not self.current_group or not self._cached_scripts:
self._display_to_cache = []
return
self._current_filter = filter_keyword
self._display_to_cache = []
if filter_keyword:
lfilter = filter_keyword.lower()
for i, content in enumerate(self._cached_scripts):
if lfilter in content.lower():
self.listbox.insert(tk.END, self._cached_display[i])
self._display_to_cache.append(i)
else:
for i, display in enumerate(self._cached_display):
self.listbox.insert(tk.END, display)
self._display_to_cache.append(i)
def refresh_appearance(self):
self.list_font.configure(size=self.config.get("list_font_size", DEFAULT_FONT_SIZE))
font_color_name = self.config.get("list_font_color", "黑色")
font_color = self.color_mgr.get_tk_color(font_color_name)
bg_color_name = self.config.get("list_bg_color", DEFAULT_BG_COLOR)
bg_color = self.color_mgr.get_tk_color(bg_color_name)
self.listbox.config(font=self.list_font, foreground=font_color, background=bg_color)
self.frame.config(bg=bg_color)
def _apply_appearance(self):
self.refresh_appearance()
def _on_select(self, event):
pass
def _swap_scripts(self, idx1: int, idx2: int):
if not self.current_group:
return
group = self.storage.get_group(self.current_group)
if not group or idx1 >= len(group.script_items) or idx2 >= len(group.script_items):
return
group.script_items[idx1], group.script_items[idx2] = \
group.script_items[idx2], group.script_items[idx1]
self.storage.mark_dirty()
self._cached_scripts[idx1], self._cached_scripts[idx2] = \
self._cached_scripts[idx2], self._cached_scripts[idx1]
self._cached_display[idx1], self._cached_display[idx2] = \
self._cached_display[idx2], self._cached_display[idx1]
item1 = self.listbox.get(idx1)
item2 = self.listbox.get(idx2)
self.listbox.delete(idx1)
self.listbox.insert(idx1, item2)
self.listbox.delete(idx2)
self.listbox.insert(idx2, item1)
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(idx2)
def _add_script_dialog(self):
if not self.current_group:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NOT_SELECTED)
return
def add(content: str):
if not content:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_CONTENT_EMPTY)
return
if self.storage.add_script(self.current_group, content):
self._load_cache()
self.refresh(self._current_filter)
TextDialog(self.frame.winfo_toplevel(), DLG_TITLE_ADD_SCRIPT,
DLG_PROMPT_SCRIPT_CONTENT, "", add)
def _edit_script(self):
if not self.current_group:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NOT_SELECTED)
return
selection = self.listbox.curselection()
if not selection:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_NOT_SELECTED)
return
idx = selection[0]
if idx < len(self._display_to_cache):
real_idx = self._display_to_cache[idx]
if real_idx < len(self._cached_scripts):
content = self._cached_scripts[real_idx]
def update(new_content: str):
if not new_content:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_CONTENT_EMPTY)
return
if self.storage.update_script(self.current_group, real_idx, new_content):
self._load_cache()
self.refresh(self._current_filter)
TextDialog(self.frame.winfo_toplevel(), DLG_TITLE_EDIT_SCRIPT,
DLG_PROMPT_EDIT_SCRIPT, content, update)
else:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_INDEX_ERROR)
else:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_INDEX_ERROR)
def _delete_script(self):
if not self.current_group:
messagebox.showwarning(WARNING_TITLE, WARN_GROUP_NOT_SELECTED)
return
selection = self.listbox.curselection()
if not selection:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_NOT_SELECTED)
return
idx = selection[0]
if idx < len(self._display_to_cache):
real_idx = self._display_to_cache[idx]
if messagebox.askyesno(CONFIRM_TITLE, CONFIRM_DELETE_SCRIPT):
if self.storage.delete_script(self.current_group, real_idx):
self._load_cache()
self.refresh(self._current_filter)
else:
messagebox.showwarning(WARNING_TITLE, WARN_SCRIPT_INDEX_ERROR)
def _use_script(self, event=None):
if not self.current_group:
return
selection = self.listbox.curselection()
if not selection:
return
idx = selection[0]
if idx < len(self._display_to_cache):
real_idx = self._display_to_cache[idx]
if real_idx < len(self._cached_scripts):
content = self._cached_scripts[real_idx]
pyperclip.copy(content)
messagebox.showinfo(INFO_TITLE, COPY_SUCCESS)
def _show_menu(self, event):
try:
index = self.listbox.nearest(event.y)
bbox = self.listbox.bbox(index)
if bbox and bbox[1] <= event.y <= bbox[1] + bbox[3]:
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(index)
self.listbox.activate(index)
self.menu.tk_popup(event.x_root, event.y_root)
finally:
self.menu.grab_release()
# ==================== 设置栏 ====================
class SettingsBar(ttk.Frame):
def __init__(self, parent, config: ConfigManager,
on_font_changed: Callable,
on_search: Callable,
on_toggle_group: Callable,
on_toggle_top: Callable):
super().__init__(parent)
self.config = config
self.color_mgr = ColorManager()
self.on_font_changed = on_font_changed
self.on_search = on_search
self.on_toggle_group = on_toggle_group
self.on_toggle_top = on_toggle_top
self.widget_factory = WidgetFactory()
self._search_debounce_id = None
self._create_widgets()
self._apply_initial_values()
def _create_widgets(self):
self.columnconfigure(0, weight=1)
self.columnconfigure(1, weight=0)
self.columnconfigure(2, weight=0)
self.columnconfigure(3, weight=0)
self.columnconfigure(4, weight=0)
self.columnconfigure(5, weight=0)
row = 0
# 搜索框
self.search_var = tk.StringVar()
self.search_entry = ttk.Entry(self, textvariable=self.search_var,
font=(DEFAULT_UI_FONT, 10))
self.search_entry.insert(0, SEARCH_PLACEHOLDER)
self.search_entry.grid(row=row, column=0, sticky="ew", padx=(5, 0))
self.search_entry.bind("<FocusIn>", self._clear_placeholder)
self.search_entry.bind("<FocusOut>", self._set_placeholder)
# 使用 trace_add 替代 trace
self.search_var.trace_add("write", self._on_search_input)
# 字体大小
font_frame = ttk.Frame(self)
font_frame.grid(row=row, column=1, padx=(5, 0))
self.font_size_var = tk.IntVar(value=self.config.get("list_font_size", DEFAULT_FONT_SIZE))
font_spinbox = tk.Spinbox(font_frame, from_=8, to=20, width=4,
textvariable=self.font_size_var,
command=self._on_font_size_changed)
font_spinbox.pack(side=tk.LEFT)
font_spinbox.bind("<Return>", lambda e: self._on_font_size_changed())
font_spinbox.bind("<FocusOut>", lambda e: self._on_font_size_changed())
self.widget_factory.create_tooltip(self, font_spinbox, TOOLTIP_FONT_SIZE)
# 字体颜色
font_color_frame = ttk.Frame(self)
font_color_frame.grid(row=row, column=2, padx=2)
self.font_color_var = tk.StringVar(value=self._get_short_font_color())
font_color_combo = ttk.Combobox(font_color_frame, textvariable=self.font_color_var,
values=FONT_COLOR_OPTIONS,
width=4, state="readonly")
font_color_combo.pack(side=tk.LEFT)
font_color_combo.bind("<<ComboboxSelected>>", lambda e: self._on_font_color_changed())
self.widget_factory.create_tooltip(self, font_color_combo, TOOLTIP_FONT_COLOR)
# 背景颜色
bg_color_frame = ttk.Frame(self)
bg_color_frame.grid(row=row, column=3, padx=2)
self.bg_color_var = tk.StringVar(value=self._get_short_bg_color())
bg_color_combo = ttk.Combobox(bg_color_frame, textvariable=self.bg_color_var,
values=BG_COLOR_OPTIONS,
width=4, state="readonly")
bg_color_combo.pack(side=tk.LEFT)
bg_color_combo.bind("<<ComboboxSelected>>", lambda e: self._on_bg_color_changed())
self.widget_factory.create_tooltip(self, bg_color_combo, TOOLTIP_BG_COLOR)
# 置顶
self.always_on_top_var = tk.BooleanVar(value=self.config.get("always_on_top", False))
top_check = ttk.Checkbutton(self, text="", variable=self.always_on_top_var)
top_check.grid(row=row, column=4, padx=2)
top_check.configure(command=self._toggle_always_on_top)
self.widget_factory.create_tooltip(self, top_check, TOOLTIP_ALWAYS_ON_TOP)
# 分组显隐按钮
self.toggle_btn = tk.Button(self, text=TOGGLE_BTN_HIDE, font=("Arial", 8),
bg="gray90", relief="raised", width=1, height=1,
command=self._toggle_group_visibility)
self.toggle_btn.grid(row=row, column=5, padx=2)
def _apply_initial_values(self):
self.font_size_var.set(self.config.get("list_font_size", DEFAULT_FONT_SIZE))
self.font_color_var.set(self._get_short_font_color())
self.bg_color_var.set(self._get_short_bg_color())
self.always_on_top_var.set(self.config.get("always_on_top", False))
def _get_short_font_color(self):
full = self.config.get("list_font_color", DEFAULT_FONT_COLOR)
return self.color_mgr.get_short_color(full, is_background=False)
def _get_short_bg_color(self):
full = self.config.get("list_bg_color", DEFAULT_BG_COLOR)
return self.color_mgr.get_short_color(full, is_background=True)
def _clear_placeholder(self, event):
if self.search_entry.get() == SEARCH_PLACEHOLDER:
self.search_entry.delete(0, tk.END)
self.search_entry.config(foreground="black")
def _set_placeholder(self, event):
if not self.search_entry.get():
self.search_entry.insert(0, SEARCH_PLACEHOLDER)
self.search_entry.config(foreground="gray")
def _on_search_input(self, *args):
if self._search_debounce_id:
self.after_cancel(self._search_debounce_id)
self._search_debounce_id = None
self._search_debounce_id = self.after(SEARCH_DEBOUNCE_MS, self._perform_search)
def _perform_search(self):
self._search_debounce_id = None
keyword = self.get_search_keyword()
self.on_search(keyword)
def _on_font_size_changed(self):
size = self.font_size_var.get()
self.config.set("list_font_size", size)
self.on_font_changed()
def _on_font_color_changed(self):
short = self.font_color_var.get()
full = self.color_mgr.get_full_color(short, is_background=False)
self.config.set("list_font_color", full)
self.on_font_changed()
def _on_bg_color_changed(self):
short = self.bg_color_var.get()
full = self.color_mgr.get_full_color(short, is_background=True)
self.config.set("list_bg_color", full)
self.on_font_changed()
def _toggle_always_on_top(self):
self.on_toggle_top(self.always_on_top_var.get())
def _toggle_group_visibility(self):
self.on_toggle_group()
def set_toggle_button_visible(self, visible: bool):
if visible:
self.toggle_btn.grid(row=0, column=5, padx=2)
else:
self.toggle_btn.grid_remove()
def update_toggle_button_text(self, group_visible: bool):
if group_visible:
self.toggle_btn.config(text=TOGGLE_BTN_HIDE)
else:
self.toggle_btn.config(text=TOGGLE_BTN_SHOW)
def get_search_keyword(self) -> str:
keyword = self.search_var.get()
return "" if keyword == SEARCH_PLACEHOLDER else keyword
# ==================== 主窗口 ====================
class ScriptManager:
def __init__(self, root: tk.Tk):
self.root = root
self.config = ConfigManager()
self.storage = DataStorage()
self.group_visible = True
self.color_mgr = ColorManager()
self.bg_color = self.color_mgr.get_tk_color(
self.config.get("list_bg_color", DEFAULT_BG_COLOR)
)
self._setup_window()
self._create_ui()
self._initialize_display()
def _setup_window(self):
geometry = self.config.get("window_geometry", WINDOW_GEOMETRY)
self.root.title(self.config.get("window_title", WINDOW_TITLE))
self.root.geometry(geometry)
try:
self.root.iconbitmap(default=ICON_FILE)
except Exception:
pass
if self.config.get("always_on_top", False):
self.root.attributes('-topmost', True)
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
def _on_closing(self):
self.config.set("window_geometry", self.root.geometry(), save=True)
# 保存分组宽度(仅在分组可见时)
if hasattr(self, 'paned') and self.group_visible:
try:
if self.paned.panes():
coord = self.paned.sash_coord(0)
if coord and isinstance(coord, tuple) and len(coord) > 0:
width = int(coord[0])
if width > 0:
self.config.set("group_panel_width", width, save=True)
except Exception:
pass
self.storage.save(async_mode=False)
self.config.save()
self.root.destroy()
def _create_ui(self):
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True)
self.settings_bar = SettingsBar(
main_frame, self.config,
on_font_changed=self._on_settings_changed,
on_search=self._on_search,
on_toggle_group=self._toggle_group_visibility,
on_toggle_top=self._toggle_always_on_top
)
self.settings_bar.pack(fill=tk.X, pady=0)
self.paned = tk.PanedWindow(
main_frame, orient=tk.HORIZONTAL,
sashrelief="raised",
sashwidth=6,
bd=0,
relief="flat"
)
self.paned.pack(fill=tk.BOTH, expand=True)
self.paned.config(bg=self.bg_color)
group_width = self.config.get("group_panel_width", 150)
self.group_panel = GroupPanel(self.paned, self.storage, self._on_group_selected)
self.paned.add(self.group_panel.frame, width=group_width)
self.script_panel = ScriptPanel(self.paned, self.storage, self.config)
self.paned.add(self.script_panel.frame, width=600)
self.settings_bar.set_toggle_button_visible(True)
def _initialize_display(self):
groups = self.storage.get_groups()
if groups:
self.group_panel.set_current_group(groups[0].name)
self.script_panel.set_group(groups[0].name)
self.script_panel.refresh()
def _on_settings_changed(self):
self.bg_color = self.color_mgr.get_tk_color(
self.config.get("list_bg_color", DEFAULT_BG_COLOR)
)
self.paned.config(bg=self.bg_color)
self.group_panel.refresh_appearance(self.bg_color)
self.script_panel.refresh_appearance()
def _on_group_selected(self, group_name: str):
self.script_panel.set_group(group_name)
self.script_panel.refresh(self.settings_bar.get_search_keyword())
def _on_search(self, keyword: str):
self.script_panel.refresh(keyword)
def _toggle_group_visibility(self):
if self.group_visible:
try:
if self.paned.panes():
coord = self.paned.sash_coord(0)
if coord and isinstance(coord, tuple) and len(coord) > 0:
width = int(coord[0])
if width > 0:
self.config.set("group_panel_width", width, save=False)
except Exception:
pass
self.paned.forget(self.group_panel.frame)
self.settings_bar.update_toggle_button_text(False)
self.group_visible = False
else:
children = self.paned.panes()
for child in children:
self.paned.forget(child)
width = self.config.get("group_panel_width", 150)
self.paned.add(self.group_panel.frame, width=width)
self.paned.add(self.script_panel.frame, width=600)
self.settings_bar.update_toggle_button_text(True)
self.group_visible = True
def _toggle_always_on_top(self, is_top: bool):
self.root.attributes('-topmost', is_top)
self.config.set("always_on_top", is_top)
# ==================== 入口 ====================
def main():
print("常用语管理")
print("=" * 50)
print("启动中...")
try:
root = tk.Tk()
# 强制窗口显示
root.deiconify()
root.lift()
root.update()
app = ScriptManager(root)
root.mainloop()
except Exception as e:
print(f"启动失败: {e}")
import traceback
traceback.print_exc()
try:
root = tk.Tk()
root.withdraw()
tk.messagebox.showerror("严重错误", f"程序启动失败:\n{e}")
root.destroy()
except:
pass
input("按Enter键退出...")
if __name__ == "__main__":
main()