写了个离线客服话术助手(粘贴板工具)

八月 03, 2026 / Ming / 1阅读 / 0评论/ 分类: 默认分类

只用 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()

成品及工程文件

#电脑软件(33)#小技巧(33)

文章作者:Ming

文章链接:https://11ao.cn/archives/FTKHqCaV

版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!


评论