写了个离线客服话术助手(粘贴板工具)
只用 Python 标准库 一个 500×400 的小窗口,解决客服每天最重复的一件事:把同一段话术,复制给不同的客户。
从一个很真实的场景说起
“您好,请问有什么可以帮您?”——这句话,客服一天可能要打几十遍。
更麻烦的是,同样一个售后问题,不同客服回复的版本五花八门:有人多打一个错别字,有人漏掉关键一句,新人更是对着空白输入框发愁。公司不是没有话术模板,但模板躺在共享文档里,要切窗口、翻目录、复制、再粘贴,一来一回十几秒就没了。
于是我想,不如做一个最朴素的工具:打开就能用,单击就复制,数据存在本机。它不联网、不登录、不弹广告,安静地待在屏幕角落。
它是什么
“客服话术助手”是一个 Windows 本地小工具,窗口始终置顶,默认 500×400,正好挂在聊天窗口旁边。它的核心交互只有三步:找到话术、单击标题、粘贴到聊天框。
如果要用三个词概括,那就是——快、简单、离线。
功能一览
话术管理:每条话术由“标题”和“完整回复内容”组成,支持添加、编辑、删除,双击任意条目即可修改。
一键复制:单击列表中的标题,内容立刻进入系统剪贴板,状态栏显示“✅ 已复制:xxx”,两秒后自动消失。选中后用回车复制也行。
实时搜索:顶部搜索框输入关键词,列表即时按标题过滤,不区分大小写;清空搜索框即恢复全部。
自动持久化:所有话术保存在程序同目录的
phrases.json里,UTF-8 明文,每次增删改都自动落盘。首次运行会自动创建这个文件,并写入三条示例话术。窗口置顶:无论你在和谁聊天,工具永远浮在最上层,随拿随用。
技术上,为什么这个项目值得一提
这个工具只有几处设计,但每处都刻意选了“最笨但最可靠”的方案:
1. 只用标准库。 界面用 tkinter,数据用 json,剪贴板用 Tk 自带的 clipboard_clear / clipboard_append,连高分屏适配都是 ctypes 调 Windows API 做的。全程没有 pyperclip、没有第三方依赖,装好 Python 就能跑,也天然断网可用——对数据敏感的内网办公环境尤其友好。
2. 数据即文件。 不用数据库,不写注册表,phrases.json 就是全部数据。备份等于复制一个文件,换电脑等于拷贝两个文件,甚至可以手工打开改两句话术再放回去。小工具就该有这样的透明感。
3. 单文件分发。 用 PyInstaller 打成单文件 exe,约 10 MB,没有控制台黑窗,双击即用。团队里哪怕完全不懂技术的同事,也能在五秒内上手。
整个软件最核心的复制逻辑,其实只有几行:
def _copy_selected_phrase(self):
"""把选中话术的内容复制到系统剪贴板。"""
data_index = self._selected_index()
if data_index is None:
return
phrase = self.phrases[data_index]
self.root.clipboard_clear()
self.root.clipboard_append(phrase["content"])
self.root.update()
self._show_status(f"✅ 已复制:{phrase['title']}")选中 → 进剪贴板 → 给个反馈。工具的意义,就是把这三件事做到毫秒级、零思考。
谁适合用它
一线客服:高峰期回复更快、更统一,告别“复制粘贴再改错别字”。
团队管理者:把标准话术收进一个文件,新同事打开就能用,话术版本也不再各自为政。
内网或离线环境:程序不发任何网络请求,数据不出本机,敏感岗位也能放心用。
使用流程简单到不用教:双击打开 → 搜索或翻找 → 单击复制 → 粘贴 → 完事。窗口想挂哪儿挂哪儿,置顶不挡事。
结语
工具的价值不在于技术多复杂,而在于它是否真的解决了某个具体的小麻烦。这个项目没有炫技,全部代码只有几百行,但它在键盘上为客服省下的每一秒,都会变成更从容的沟通和更统一的服务。
如果你也常被“复制粘贴”这类琐事打扰,不妨试试这个思路:找一件天天重复的小事,写个小工具,把它做到一键完成。
源码
# -*- coding: utf-8 -*-
"""
====================================================================
客服话术助手 —— 离线 Windows 桌面小工具
====================================================================
功能简介
1. 话术管理:添加、编辑、删除常用客服短语(标题 + 完整内容)。
2. 快速复制:单击列表项或按回车,立即把话术内容复制到剪贴板。
3. 数据持久化:所有短语保存在程序同目录下的 phrases.json 中。
4. 搜索过滤:顶部搜索框实时按标题过滤(不区分大小写)。
5. 窗口行为:始终置顶、大小可调、关闭即退出,完全离线运行。
运行方式
python 客服话术助手.py
打包成 exe(需要 PyInstaller)
pyinstaller --onefile --windowed --name=客服话术助手 客服话术助手.py
依赖说明
仅使用 Python 3 标准库(tkinter / json / os / sys / ctypes),
不依赖任何第三方库,也没有任何网络请求。
====================================================================
"""
import ctypes
import json
import os
import sys
import tkinter as tk
from tkinter import font as tkfont
from tkinter import messagebox
# ====================================================================
# 一、全局配置
# ====================================================================
APP_TITLE = "客服话术助手"
DATA_FILE = "phrases.json" # 数据文件名(保存到程序同目录)
PLACEHOLDER_TEXT = "搜索话术…" # 搜索框的灰色提示文字
CLICK_DELAY_MS = 200 # 单击延迟判定,用于区分“单击复制/双击编辑”
STATUS_CLEAR_MS = 2000 # 状态提示自动清除时间(2 秒)
# 首次启动时自动写入的示例话术
DEFAULT_PHRASES = [
{"title": "通用问候", "content": "您好,请问有什么可以帮您?"},
{"title": "通用结束语", "content": "感谢您的咨询,祝您生活愉快!如后续还有其他问题,欢迎随时联系。"},
{"title": "请稍等", "content": "您好,您的问题我需要核实一下,请稍等片刻,马上为您处理。"},
]
# 界面配色(清爽办公风,可按喜好修改)
COLOR_BG = "#f5f6f8" # 窗口背景
COLOR_SELECT_BG = "#cde3ff" # 列表选中背景(浅蓝)
COLOR_TEXT = "#1f1f1f" # 正文颜色
COLOR_PLACEHOLDER = "#9aa0a6" # 搜索框提示文字(灰色)
COLOR_STATUS = "#666666" # 状态栏默认颜色
COLOR_OK = "#1e7f3d" # 复制成功提示(绿色)
COLOR_WARN = "#c0392b" # 删除/异常提示(红色)
def data_file_path():
"""返回 phrases.json 的完整路径。
开发时运行:脚本所在目录。
打包成 exe 后:exe 所在目录,确保数据文件生成在用户看得到的地方。
"""
if getattr(sys, "frozen", False): # PyInstaller 打包后存在 sys.frozen
base_dir = os.path.dirname(sys.executable)
else:
base_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_dir, DATA_FILE)
def enable_dpi_awareness():
"""开启 Windows 高分屏 DPI 感知,避免界面文字模糊(非 Windows 环境自动跳过)。"""
if sys.platform == "win32":
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass # 个别系统不支持时忽略即可
def setup_default_font():
"""把全局默认字体设为微软雅黑,中文显示更美观;失败则使用系统默认字体。"""
try:
tkfont.nametofont("TkDefaultFont").configure(family="Microsoft YaHei UI", size=10)
tkfont.nametofont("TkTextFont").configure(family="Microsoft YaHei UI", size=10)
except tk.TclError:
pass
# ====================================================================
# 二、添加/编辑话术的模态子窗口
# ====================================================================
class PhraseDialog(tk.Toplevel):
"""添加/编辑话术的模态子窗口。
mode 为 "add"(添加话术)或 "edit"(编辑话术)。
保存成功后 self.result 为 {"title": ..., "content": ...},否则为 None。
"""
def __init__(self, parent, mode, phrase=None):
super().__init__(parent)
self.result = None # 保存结果,供主窗口读取
phrase = phrase or {} # 编辑时传入原短语
self.title("编辑话术" if mode == "edit" else "添加话术")
self.configure(bg=COLOR_BG)
self.resizable(False, False) # 固定大小
self.transient(parent) # 依附于主窗口
self.attributes("-topmost", True) # 确保弹窗显示在最前
# ---------- 标题输入框 ----------
title_row = tk.Frame(self, bg=COLOR_BG)
title_row.pack(fill="x", padx=14, pady=(14, 6))
tk.Label(title_row, text="标题:", bg=COLOR_BG).pack(side="left")
self.title_var = tk.StringVar(value=phrase.get("title", ""))
self.title_entry = tk.Entry(title_row, textvariable=self.title_var, relief="solid", bd=1)
self.title_entry.pack(side="left", fill="x", expand=True)
# ---------- 话术内容输入框(多行,带滚动条) ----------
tk.Label(self, text="话术内容:", bg=COLOR_BG, anchor="w").pack(fill="x", padx=14, pady=(0, 4))
text_wrap = tk.Frame(self, bg=COLOR_BG)
text_wrap.pack(fill="both", expand=True, padx=14)
self.content_text = tk.Text(
text_wrap, height=9, wrap="word", undo=True,
relief="solid", bd=1, padx=6, pady=6,
)
self.content_text.pack(side="left", fill="both", expand=True)
scrollbar = tk.Scrollbar(text_wrap, command=self.content_text.yview)
scrollbar.pack(side="right", fill="y")
self.content_text.config(yscrollcommand=scrollbar.set)
self.content_text.insert("1.0", phrase.get("content", ""))
# ---------- 按钮区 ----------
btn_row = tk.Frame(self, bg=COLOR_BG)
btn_row.pack(fill="x", padx=14, pady=(8, 14))
save_btn = tk.Button(btn_row, text="保存", width=10, command=self._on_save)
save_btn.pack(side="right")
cancel_btn = tk.Button(btn_row, text="取消", width=10, command=self.destroy)
cancel_btn.pack(side="right", padx=(0, 10))
# 快捷键:回车保存,Esc 取消
self.bind("<Return>", lambda _event: self._on_save())
self.bind("<Escape>", lambda _event: self.destroy())
# 显示在主窗口上方居中位置,并设为模态(禁止操作主窗口)
self._center_on_parent(parent)
self.wait_visibility()
self.grab_set()
self.title_entry.focus_set()
def _center_on_parent(self, parent):
"""把弹窗放在主窗口上方居中。"""
self.update_idletasks()
width, height = self.winfo_reqwidth(), self.winfo_reqheight()
x = parent.winfo_rootx() + (parent.winfo_width() - width) // 2
y = parent.winfo_rooty() + (parent.winfo_height() - height) // 3
self.geometry(f"{width}x{height}+{max(x, 0)}+{max(y, 0)}")
def _on_save(self):
"""校验输入并保存结果。"""
title = self.title_var.get().strip()
content = self.content_text.get("1.0", "end-1c").strip()
if not title:
messagebox.showwarning("提示", "标题不能为空,请填写标题。", parent=self)
self.title_entry.focus_set()
return
if not content:
messagebox.showwarning("提示", "话术内容不能为空,请填写内容。", parent=self)
self.content_text.focus_set()
return
self.result = {"title": title, "content": content}
self.destroy()
# ====================================================================
# 三、主程序
# ====================================================================
class PhraseApp:
"""客服话术助手主窗口。"""
def __init__(self, root):
self.root = root
self.phrases = [] # 全部话术:[{"title": ..., "content": ...}]
self.filtered_indices = [] # 当前列表显示的内容在原列表中的下标
self._click_after = None # 单击延迟任务句柄
self._status_after = None # 状态提示自动清除任务句柄
self._setup_window()
self._build_ui()
self._load_data()
self._refresh_list()
# ---------- 窗口基础设置 ----------
def _setup_window(self):
self.root.title(APP_TITLE)
self.root.geometry("500x400") # 默认大小约 500x400
self.root.minsize(420, 300) # 允许调小但不要太离谱
self.root.attributes("-topmost", True) # 窗口始终置顶
self.root.protocol("WM_DELETE_WINDOW", self._on_close) # 关闭即保存并退出
# ---------- 界面构建 ----------
def _build_ui(self):
root = self.root
root.configure(bg=COLOR_BG)
# --- 顶部:搜索框 + 添加按钮 ---
top_bar = tk.Frame(root, bg=COLOR_BG)
top_bar.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 6))
top_bar.columnconfigure(0, weight=1)
self.search_entry = tk.Entry(top_bar, relief="solid", bd=1, fg=COLOR_PLACEHOLDER)
self.search_entry.grid(row=0, column=0, sticky="ew", padx=(0, 8))
self.add_btn = tk.Button(top_bar, text="添加短语", width=12, command=self._open_add_dialog)
self.add_btn.grid(row=0, column=1)
# --- 中部:话术列表 ---
list_wrap = tk.Frame(root, bg=COLOR_BG)
list_wrap.grid(row=1, column=0, sticky="nsew", padx=10, pady=4)
list_wrap.rowconfigure(0, weight=1)
list_wrap.columnconfigure(0, weight=1)
self.listbox = tk.Listbox(
list_wrap,
activestyle="none", # 选中项不高亮下划线
selectbackground=COLOR_SELECT_BG,
selectforeground=COLOR_TEXT,
relief="solid", bd=1,
highlightthickness=0,
)
self.listbox.grid(row=0, column=0, sticky="nsew")
scrollbar = tk.Scrollbar(list_wrap, orient="vertical", command=self.listbox.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.listbox.config(yscrollcommand=scrollbar.set)
# --- 底部:状态栏 + 编辑/删除按钮 ---
bottom_bar = tk.Frame(root, bg=COLOR_BG)
bottom_bar.grid(row=2, column=0, sticky="ew", padx=10, pady=(6, 10))
bottom_bar.columnconfigure(0, weight=1)
self.status_label = tk.Label(
bottom_bar, text="单击复制 · 双击编辑", anchor="w",
bg=COLOR_BG, fg=COLOR_STATUS,
)
self.status_label.grid(row=0, column=0, sticky="ew")
self.edit_btn = tk.Button(bottom_bar, text="编辑选中", width=10, command=self._open_edit_dialog)
self.edit_btn.grid(row=0, column=1, padx=(8, 4))
self.delete_btn = tk.Button(bottom_bar, text="删除选中", width=10, command=self._delete_selected)
self.delete_btn.grid(row=0, column=2, padx=(4, 0))
root.rowconfigure(1, weight=1) # 列表区域随窗口拉伸
root.columnconfigure(0, weight=1)
# --- 搜索框事件:灰色提示文字 + 实时过滤 ---
self.search_entry.insert(0, PLACEHOLDER_TEXT)
self.search_entry.bind("<FocusIn>", self._on_search_focus_in)
self.search_entry.bind("<FocusOut>", self._on_search_focus_out)
self.search_entry.bind("<KeyRelease>", lambda _event: self._refresh_list())
# --- 列表交互:单击复制、双击编辑、回车复制 ---
self.listbox.bind("<ButtonRelease-1>", self._on_single_click)
self.listbox.bind("<Double-Button-1>", self._on_double_click)
self.listbox.bind("<Return>", self._on_enter_key)
self.listbox.bind("<<ListboxSelect>>", lambda _event: self._update_buttons_state())
# ---------- 搜索框:灰色提示文字 ----------
def _on_search_focus_in(self, _event=None):
"""获得焦点时:若仍是提示文字则清空并恢复正常颜色。"""
if self.search_entry.get() == PLACEHOLDER_TEXT:
self.search_entry.delete(0, tk.END)
self.search_entry.config(fg=COLOR_TEXT)
def _on_search_focus_out(self, _event=None):
"""失去焦点时:若为空则恢复提示文字。"""
if not self.search_entry.get().strip():
self.search_entry.insert(0, PLACEHOLDER_TEXT)
self.search_entry.config(fg=COLOR_PLACEHOLDER)
self._refresh_list()
def _reset_search(self):
"""清空搜索框并恢复提示文字(添加/编辑后保证新数据可见)。"""
self.search_entry.delete(0, tk.END)
self.search_entry.insert(0, PLACEHOLDER_TEXT)
self.search_entry.config(fg=COLOR_PLACEHOLDER)
# ---------- 数据读写 ----------
def _load_data(self):
"""启动时加载 phrases.json;文件不存在则创建并写入示例话术。"""
path = data_file_path()
if not os.path.exists(path):
self.phrases = [dict(item) for item in DEFAULT_PHRASES]
self._save_data()
return
try:
with open(path, "r", encoding="utf-8") as fp:
raw_data = json.load(fp)
except (OSError, json.JSONDecodeError) as exc:
# 数据文件损坏时不崩溃,提示后以空列表启动
messagebox.showwarning(
"数据文件异常",
f"phrases.json 无法读取({exc}),本次以空列表启动。\n"
"如果数据重要,请先备份该文件。",
parent=self.root,
)
self.phrases = []
return
# 只保留结构合法的条目,防止脏数据导致程序出错
self.phrases = []
for item in raw_data:
if isinstance(item, dict) and "title" in item and "content" in item:
self.phrases.append({
"title": str(item["title"]),
"content": str(item["content"]),
})
def _save_data(self):
"""把当前话术列表写入 phrases.json(UTF-8,缩进便于手工查看)。"""
path = data_file_path()
try:
with open(path, "w", encoding="utf-8") as fp:
json.dump(self.phrases, fp, ensure_ascii=False, indent=2)
except OSError as exc:
messagebox.showerror(
"保存失败",
f"无法写入数据文件:\n{path}\n\n错误信息:{exc}",
parent=self.root,
)
# ---------- 列表刷新与选择 ----------
def _refresh_list(self):
"""按搜索关键词刷新列表显示(标题过滤,不区分大小写)。"""
# 取消可能未触发的单击复制任务,避免误操作
if self._click_after is not None:
self.root.after_cancel(self._click_after)
self._click_after = None
query = self.search_entry.get().strip()
if query and query != PLACEHOLDER_TEXT:
keyword = query.lower()
self.filtered_indices = [
i for i, phrase in enumerate(self.phrases)
if keyword in phrase["title"].lower()
]
else:
# 未输入关键词(或仍是提示文字)时显示全部
self.filtered_indices = list(range(len(self.phrases)))
self.listbox.delete(0, tk.END)
for i in self.filtered_indices:
self.listbox.insert(tk.END, self.phrases[i]["title"])
self._update_buttons_state()
def _selected_index(self):
"""返回当前选中项对应的原始数据下标;没有选中则返回 None。"""
selection = self.listbox.curselection()
if not selection:
return None
position = selection[0]
if position >= len(self.filtered_indices):
return None
return self.filtered_indices[position]
def _select_in_list(self, data_index):
"""选中指定原始数据下标对应的列表项,并滚动到可见位置。"""
if data_index in self.filtered_indices:
position = self.filtered_indices.index(data_index)
self.listbox.selection_clear(0, tk.END)
self.listbox.selection_set(position)
self.listbox.activate(position)
self.listbox.see(position)
self._update_buttons_state()
def _update_buttons_state(self):
"""没有选中项时禁用“编辑选中 / 删除选中”按钮。"""
has_selection = self._selected_index() is not None
state = "normal" if has_selection else "disabled"
self.edit_btn.config(state=state)
self.delete_btn.config(state=state)
# ---------- 列表交互:单击复制 / 双击编辑 / 回车复制 ----------
def _on_single_click(self, _event=None):
"""单击:延迟一小段后复制,方便与双击区分。"""
if self._click_after is not None:
self.root.after_cancel(self._click_after)
self._click_after = self.root.after(CLICK_DELAY_MS, self._copy_selected_phrase)
def _on_double_click(self, _event=None):
"""双击:取消单击的复制任务,改为打开编辑窗口。"""
if self._click_after is not None:
self.root.after_cancel(self._click_after)
self._click_after = None
self._open_edit_dialog()
def _on_enter_key(self, _event=None):
"""回车:复制当前选中项。"""
self._copy_selected_phrase()
return "break" # 阻止 Listbox 默认行为
def _copy_selected_phrase(self):
"""把选中话术的内容复制到系统剪贴板。"""
data_index = self._selected_index()
if data_index is None:
return
phrase = self.phrases[data_index]
self.root.clipboard_clear()
self.root.clipboard_append(phrase["content"])
self.root.update() # 立即刷新剪贴板内容
self._show_status(f"✅ 已复制:{phrase['title']}", color=COLOR_OK)
# ---------- 状态栏 ----------
def _show_status(self, text, color=COLOR_STATUS):
"""显示状态提示,并安排 2 秒后自动清除。"""
if self._status_after is not None:
self.root.after_cancel(self._status_after) # 取消旧任务,防止提前清除
self.status_label.config(text=text, fg=color)
self._status_after = self.root.after(STATUS_CLEAR_MS, self._clear_status)
def _clear_status(self):
"""清除状态提示。"""
self._status_after = None
self.status_label.config(text="", fg=COLOR_STATUS)
# ---------- 添加 / 编辑 / 删除 ----------
def _open_add_dialog(self):
"""打开“添加话术”窗口。"""
dialog = PhraseDialog(self.root, mode="add")
self.root.wait_window(dialog) # 模态等待
if not dialog.result:
return
self.phrases.append(dialog.result)
self._save_data()
self._reset_search() # 清空搜索,确保新短语可见
self._refresh_list()
self._select_in_list(len(self.phrases) - 1)
self._show_status(f"已添加:{dialog.result['title']}")
def _open_edit_dialog(self):
"""打开“编辑话术”窗口(编辑当前选中项)。"""
data_index = self._selected_index()
if data_index is None:
return
phrase = self.phrases[data_index]
dialog = PhraseDialog(self.root, mode="edit", phrase=phrase)
self.root.wait_window(dialog)
if not dialog.result:
return
self.phrases[data_index] = dialog.result
self._save_data()
self._refresh_list()
self._select_in_list(data_index)
self._show_status("已保存修改")
def _delete_selected(self):
"""删除当前选中项(带二次确认)。"""
data_index = self._selected_index()
if data_index is None:
return
title = self.phrases[data_index]["title"]
if not messagebox.askyesno("删除确认", f"确定要删除话术「{title}」吗?", parent=self.root):
return
self.phrases.pop(data_index)
self._save_data()
self._refresh_list()
self._show_status(f"已删除:{title}", color=COLOR_WARN)
# ---------- 关闭程序 ----------
def _on_close(self):
"""关闭窗口:保存数据后退出程序。"""
if self._status_after is not None:
self.root.after_cancel(self._status_after)
self._save_data()
self.root.destroy()
# ====================================================================
# 四、程序入口
# ====================================================================
def main():
enable_dpi_awareness()
root = tk.Tk()
setup_default_font()
PhraseApp(root)
root.mainloop()
if __name__ == "__main__":
main()
成品及工程文件
文章作者:Ming
文章链接:https://11ao.cn/archives/FTKHqCaV
版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!
评论