ARTICLE · 1271445
批量 API 巡检台源码:Postman 做精修、这个做批量探活
Postman 给开发调单个接口刚好,但运维值班每天要做的是把 50 个 URL 挨个敲一遍看谁挂了、谁慢了——这两件事不是一个工具能同时做好的。这个「批量 API 调用台」是纯本地 Python 小工具:API 库(JSON 持久化 + 搜索 + 去重)→ Excel/CSV 批量导入 URL 清单 → 后台线程串行发 GET(不并发不打挂生产)→ Treeview 原位回填 + 颜色标签 → pandas 一键导出 xlsx 报告。本文先完整走一遍功能,再拆解 8 个技术要点(Tkinter 线程安全那一条是写 GUI 工具的通用铁律),最后附今天刚踩的 PyInstaller 打包工具坑(requests / pandas 被 EXCLUDE_MODULES 默认排除 →
ModuleNotFoundError,解法是给打包工具加 per-tool 放行机制)。
一、它能做什么

| 能力 | 亮点 |
|---|---|
| API 库管理 | JSON 持久化(data/api_library.json)、UUID 去重、搜索过滤、单独添加 / 批量添加到调用列表、从调用列表反向保存回库 |
| URL 批量管理 | 手动添加 / 单选 / 多选删除 / 一键清空 / Excel / CSV 批量导入(pandas,自动识别 URL 列) |
| 批量调用 | 后台线程跑,不卡死 UI;串行 GET、10 秒超时;进度条实时走;失败不中断,每个 URL 独立记结果 |
| 结果展示 | Treeview 六列(序号 / URL / 状态码 / 内容长度 / 响应时间 / 结果);2xx 绿底 / 4xx5xx 橙底 / 异常红底 |
| 导出归档 | pandas 写 xlsx / CSV;还能一键自动保存到 data/api_results/ 目录 + 打开文件夹 |
| 右键快捷 | Treeview 右键菜单:删除选中 / 保存到 API 库 / 查看详情 |
| 打包兼容 | sys.frozen 自动识别脚本 / 打包两种身份,数据目录始终在「程序所在目录 / data」 |
刻意不做:POST / PUT / DELETE(只发 GET)、自定义 Header / Body、鉴权、并发、代理——这是画好的边界,复杂请求构造请交给 Postman,本工具只负责「批量探活 + 出报告」。
二、界面结构
┌────────── 蓝紫渐变横幅:API 调用工具 v3.0 ──────────┐
├───────────────────────────────────────────────────────┤
│ ┌─ 左栏 ─────────────┐ ┌─ 右栏 ────────────────────┐ │
│ │ 📚 API 库管理 │ │ 📋 API 调用列表 │ │
│ │ 统计: 12 个 API │ │ ┌─────────────────────────┐│ │
│ │ 管理API库 / 添加 / │ │ │序号 │URL │状态码 │长度 ││ │
│ │ 批量加到调用列表 │ │ │ 1 │http..│200 │1234 ││ │
│ └───────────────────┘ │ │ │ 2 │https│500 │ 0 ││ │
│ ┌─ URL 管理 ────────┐ │ │ │ 3 │http..│超时 │ - ││ │
│ │ ➕添加URL 🗑删除选中│ │ │ └─────────────────────────┘│ │
│ │ 💾保存到库 🗑清空 │ │ └───────────────────────────┘│ │
│ │ 📥导入URLs 📋模板 │ │ ┌─ 📄 响应详情 ────────────┐ │
│ └───────────────────┘ │ │ │ 点某行显示完整信息 │ │
│ │ │ └──────────────────────────┘ │
│ │ │ ▶️调用 APIs 📥一键保存 📤导出 │
│ │ │ [========== 进度条 ==========] │
│ │ └──────────────────────────────┘ │
├───────────────────────────────────────────────────────┤
│ 状态栏:正在调用 http://...:4000... │
└───────────────────────────────────────────────────────┘
操作流:导入 URL 清单 → 点「调用 APIs」→ 看结果 → 「一键保存」自动存 Excel。全程 3 个动作,值班同学 10 秒上手。
三、技术点 1:Tkinter 多线程的唯一铁律——self.after(0, ...) 排队回主线程
批量调用 50 个 URL,每个最坏等 10 秒。如果在主线程跑,窗口直接白屏无响应。但 Tkinter 不是线程安全的——子线程直接碰控件,随时可能段崩溃。
唯一安全的姿势:活儿在子线程干,界面更新一律 self.after(0, callback) 排队回主线程。
defcall_apis(self):
self.call_button.config(state=tk.DISABLED) # 禁用按钮防重复
self.progress['maximum'] = len(self.urls)
thread = threading.Thread(target=self._call_apis_threaded, daemon=True)
thread.start()
def_call_apis_threaded(self):
fori, urlinenumerate(self.urls):
try:
self.after(0, self.update_status, f"正在调用: {url[:40]}...")
start_time = datetime.now()
response = requests.get(url, timeout=10)
elapsed = (datetime.now() -start_time).total_seconds()
# 绝不直接碰 Treeview,after 回主线程
self.after(0, self.update_tree_item, i, url,
response.status_code, len(response.content), elapsed, ...)
exceptrequests.RequestExceptionase:
self.after(0, self.update_tree_item, i, url, "❌ 错误",
str(e), 0, "❌ 请求失败", 'error')
self.after(0, self.progress.step, 1)
self.after(0, self.call_complete) # 最后一个恢复按钮
三个关键细节:
daemon=True:守护线程,关窗口时不用手动 join,进程退出线程自动带走,避免"窗口关了进程还挂着"requests.RequestException:一个异常族兜住超时、DNS 失败、连接拒绝、SSL 所有网络层问题,出错的行写错误信息而不是让整批崩掉调用期间三个按钮
state=DISABLED,结束在call_complete里统一恢复,防止用户重复点击起一堆线程
after(0, callback) 的含义是"主线程忙完当前事件后尽快执行",它把子线程产生的数据安全地投递回 Tk 的事件循环。这是所有 Tkinter 耗时任务的通用解法——记这一个模式就够了。
四、技术点 2:故意串行,不做并发
新手写这种工具第一反应是上线程池:50 个 URL 同时发,多快。本工具故意用了串行循环,原因有三个:
巡检工具的第一目标是安全,不是速度。对着生产环境 200 个接口同时开 50 并发,和一次小型 CC 攻击没区别——本来是去查谁挂了,结果把没挂的也打挂了
串行的响应时间才可比。并发下大家抢连接池、抢 CPU,测出来的响应时间互相污染,没法作为性能证据
结果表格按顺序回填最简单(见下一节),不用担心乱序
配合 timeout=10,最坏总时长是可预估的:URL 数 × 10 秒。真要加速,正解是让用户自己决定加并发数(旋钮在他手里),而不是工具默认替他冒险。
五、技术点 3:Treeview 按「位置」原位更新——别拿序号当 iid
URL 列表是先全部插入 Treeview、调用时再逐行回填结果的。这里有个坑:Treeview 的行标识 item_id 不一定等于插入顺序——删除过行之后,iid 会乱,不能想当然地拿序号当 id。
稳妥做法是每次取当前所有子项,按下标拿真正的 iid:
defupdate_tree_item(self, index, url, code, length, elapsed, text, tag=''):
ifindex<len(self.tree.get_children()):
item_id = self.tree.get_children()[index] # 位置 → 真实 iid
self.tree.item(
item_id,
values=(index+1, url, code, length,
f"{elapsed:.3f}s"ifisinstance(elapsed, (int, float)) elseelapsed,
text),
tags=(tag,) iftagelse ()
)
颜色区分通过 Treeview 的 tags 机制实现,提前给 tag 配好底色:
self.tree.tag_configure('success', background='#e8f8f0') # 2xx 成功浅绿
self.tree.tag_configure('warning', background='#fdf3e0') # 4xx / 5xx 浅橙
self.tree.tag_configure('error', background='#fdeaea') # 异常浅红
状态码分级直白:2xx 成功、4xx 客户端错误、5xx 服务器错误、其余归为"其他状态"——运维一眼扫过去知道哪行有问题。
六、技术点 4:API 库用 UUID + 时间戳做唯一标识
API 库存 JSON(data/api_library.json),增删改查要能稳定对应同一条记录。最简单的做法是 URL 当 key,但用户改了 URL 就找不到了;更好的做法是给每条记录一个不重复的 id。
defload_api_library(self):
library_file = os.path.join(_get_module_data_dir(), "api_library.json")
ifnotos.path.exists(library_file):
return []
withopen(library_file, 'r', encoding='utf-8') asf:
library = json.load(f)
forapiinlibrary:
if'id'notinapi:
api['id'] = str(uuid.uuid4()) # 老数据补 id,向后兼容
returnlibrary
# 新增时
api_id = str(uuid.uuid4())
self.api_library.append({
'id': api_id,
'name': name,
'url': url,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
四个配套设计:
UUID 去重:
if any(api.get('url') == url for api in self.api_library)先查 URL 是否已存在,重复就弹警告搜索框实时过滤:
search_var.trace("w", lambda *args: search_api())每敲一个字符重新过滤,name / url 两个字段同时匹配添加时间自动记录:
added_time字段,方便回顾"这个 API 什么时候加的"老数据向后兼容:
load_api_library里给没有 id 的旧记录补上 UUID,升级不炸
七、技术点 5:pandas 做批量导入导出——模糊匹配 URL 列 + CSV 编码兜底
几十个 URL 没人愿意手敲。导入走 Excel / CSV,pandas 读写各一行,但有两个细节容易踩:
第一,别假设 URL 列名就是 "URL"。不同人存的表头可能是 url、API地址、接口地址、api_endpoint……程序做模糊匹配:
url_col = None
name_col = None
forcolindf.columns:
col_lower = col.lower()
if'url'incol_lower:
url_col = col
elif'名称'incol_loweror'name'incol_lower:
name_col = col
ifurl_colisNoneandlen(df.columns) >0:
url_col = df.columns[0] # 都没匹配上就拿第一列,假设那是 URL
第二,CSV 编码不只有 UTF-8。国内同事用 Excel 存 CSV 默认是 GBK,直接读会炸。做三档兜底:
encodings = ['utf-8', 'gbk', 'latin1']
df = None
forencodinginencodings:
try:
df = pd.read_csv(filename, encoding=encoding)
break
exceptException:
continue
ifdfisNone:
raiseException("无法解析文件编码")
导出更简单——self.results 内存里每一项就是固定键的字典(序号 / URL / 状态码 / 内容长度 / 响应时间(秒) / 结果),这是关键设计:让内存中的数据结构和最终报表的列一一对应,pd.DataFrame(self.results).to_excel(path, index=False) 直接落盘,不用任何转换。
八、技术点 6:一键自动保存 + 打开结果目录
点「▶️ 调用 APIs」跑完后,用户第一件事是要一份报告存档。一键保存按钮自动把 Excel 写到 data/api_results/ 目录:
defquick_save(self):
results_dir = os.path.join(_get_module_data_dir(), 'api_results')
os.makedirs(results_dir, exist_ok=True)
filename = f"api_results_{len(self.results)}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
filepath = os.path.join(results_dir, filename)
pd.DataFrame(self.results).to_excel(filepath, index=False)
ifmessagebox.askyesno("打开文件夹", "是否打开结果文件夹?"):
os.startfile(results_dir) # Windows 专属,双击体验
自动打开文件夹这步是用户反复提的需求——跑完了 Excel 存哪了?直接帮他打开省得找。
九、技术点 7:数据目录兼容脚本 / 打包两种身份
API 库和结果文件要持久化,存哪?开发时文件在源码目录,PyInstaller 打包后文件在 exe 旁边,两个路径不一样。用 sys.frozen 判断运行身份:
def_get_module_data_dir(name):
ifgetattr(sys, 'frozen', False):
root = os.path.dirname(sys.executable) # 打包后:exe 同级
else:
root = os.path.dirname(os.path.abspath(__file__)) # 脚本:py 同级
returnos.path.join(root, "data")
这样无论 python api_client.py 还是双击 exe,data/api_library.json 和 data/api_results/ 都生成在"程序所在目录 / data",绿色软件,卸载就是删文件夹。想在两台电脑间迁移 API 库,拷一个 JSON 就行。
十、技术点 8:打包工具的 EXCLUDE_MODULES 陷阱——requests / pandas 默认被排除
今天刚踩的坑,专门加一节给你。
打包工具(packager.py)顶部有一个 EXCLUDE_MODULES 列表——因为项目里绝大多数小工具只依赖标准库,PyInstaller 打包时排除 pandas / requests 等大型库能显著减小体积(本来可能 80MB,排完只剩 15MB)。但 API 调试器偏偏需要这两个库,直接打包出的 exe 启动就报:
ModuleNotFoundError: No module named 'requests'
ModuleNotFoundError: No module named 'pandas'
解法是在打包工具里加一个 FORCED_INCLUDE 字典,声明哪些工具需要额外放行哪些模块,打包时动态把这些模块从 EXCLUDE_MODULES 里剔除:
# packager.py 顶部新增
EXCLUDE_MODULES = [
"numpy", "pandas", "matplotlib", "scipy", "sympy", "PIL", "cv2",
"PyQt5", "PyQt6", "requests", "urllib3", "certifi", "charset_normalizer",
...
]
# 新增:少数工具使用了非标准库,打包时必须放行
FORCED_INCLUDE = {
"18-API调试器": ["requests", "urllib3", "certifi", "charset_normalizer",
"pandas", "numpy", "python-dateutil", "pytz"],
"api_client": ["requests", "urllib3", "certifi", "charset_normalizer",
"pandas", "numpy", "python-dateutil", "pytz"],
}
打包命令生成那里,原来直接遍历 EXCLUDE_MODULES 加 --exclude-module;改后根据工具名动态计算实际排除列表:
# 原来
formodinEXCLUDE_MODULES:
cmd += ["--exclude-module", mod]
# 改成:剔除 FORCED_INCLUDE 里声明的模块
forced = set(FORCED_INCLUDE.get(name, [])) # 按工具名匹配
forced |= set(FORCED_INCLUDE.get(script.stem, [])) # 或脚本文件名匹配
excluded = [mforminEXCLUDE_MODULESifmnotinforced]
formodinexcluded:
cmd += ["--exclude-module", mod]
ifforced:
self.log_q.put(("log", f"📦 放行非标准库:{', '.join(sorted(forced))}\n", "dim"))
以后新工具如果也用了非标准库,只需要在 FORCED_INCLUDE 里加一行就行,不用改 EXCLUDE_MODULES 整体。
十一、边界处理速查表
| 情况 | 处理 |
|---|---|
| URL 列表为空点调用 | 弹 warning "没有 URL 可调用" |
| 网络异常(超时 / DNS / 连接拒绝) | 单行记 ❌ 请求失败 + 错误信息,不中断整体 |
| 线程运行中重复点调用 | 按钮 state=DISABLED,结束恢复 |
| 删除过的行,序号重排 | renumber_items() 遍历 Treeview 重新标序号 |
| API 库里重复 URL | if any(api.get('url') == url ...) 弹警告 |
| 导入 Excel 没有 URL 列 | 拿第一列兜底 + 弹提示 |
| CSV 编码非 UTF-8 | utf-8 → gbk → latin1 三档自动探测 |
| 没有 pandas | 启动即报错(pandas 是硬依赖,没法降级) |
| 打包后 Python 版本不匹配 | 两个解释器各自 pip install(py vs D:/Python/Python39/python.exe) |
| PyInstaller EXCLUDE_MODULES 排除了 requests / pandas | FORCED_INCLUDE 放行(见上一节) |
十二、依赖
requests → HTTP 探测
pandas + openpyxl → Excel / CSV 读写
tkinter / json / threading / datetime / uuid / os → 全标准库
十三、完整源码
以下是 api_client.py 全文(1320 行)。独立运行版入口在文件末尾,直接 python api_client.py 启动。如果走主工具箱(通过 set_dependencies 注入颜色 / 主应用引用),调用方式和其他小工具一致。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
importtkinterastk
fromtkinterimportttk, messagebox, filedialog, simpledialog
importrequests
importthreading
importpandasaspd
importjson
importos
fromdatetimeimportdatetime
fromtkinterimportscrolledtext
importtkinter.fontastkFont
importuuid
importosas_os, sysas_sys
def_get_module_data_dir(name):
"""toolbox 独立运行模式:返回工具自身目录下的 data/ 子目录。
PyInstaller 打包后 (_sys.frozen) 返回 exe 同级目录下的 data/。"""
ifgetattr(_sys, 'frozen', False):
root = _os.path.dirname(_sys.executable)
else:
root = _os.path.dirname(_os.path.abspath(__file__))
return_os.path.join(root, "data")
classAPICallerFrame(tk.Frame):
def__init__(self, parent):
super().__init__(parent)
self.main_app = None
self.colors = None
self.pack(fill=tk.BOTH, expand=True)
self.default_colors = {
'primary': '#1a237e', 'secondary': '#283593',
'success': '#2e7d32', 'warning': '#ff8f00', 'danger': '#c62828',
'light': '#f5f5f5', 'dark': '#212121', 'background': '#ffffff',
'card_bg': '#ffffff', 'border': '#e0e0e0', 'highlight': '#e3f2fd',
}
self.font_title = ("微软雅黑", 14, "bold")
self.font_large = ("微软雅黑", 11)
self.font_normal = ("微软雅黑", 10)
self.font_small = ("微软雅黑", 9)
self.urls = []
self.results = []
self.api_library = self.load_api_library()
self.library_stats = None
self.stats_label = None
self.tree = None
self.detail_text = None
self.call_button = None
self.quick_save_button = None
self.export_button = None
self.progress = None
defset_dependencies(self, colors=None, username=None, user_type=None,
data_manager=None, main_app=None, **kwargs):
ifcolors:
self.colors = self.default_colors.copy()
self.colors.update(colors)
else:
self.colors = self.default_colors
self.main_app = main_app
self.create_interface()
self.configure_styles()
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)
defcreate_interface(self):
forwidgetinself.winfo_children():
widget.destroy()
main_content = tk.Frame(self, bg=self.colors['background'])
main_content.pack(fill=tk.BOTH, expand=True, padx=15, pady=15)
left_panel = tk.Frame(main_content, bg=self.colors['background'])
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 10))
self.create_api_library_card(left_panel)
self.create_url_management_card(left_panel)
right_panel = tk.Frame(main_content, bg=self.colors['background'])
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
self.create_api_list_card(right_panel)
self.create_detail_action_card(right_panel)
self.update_library_stats()
# ---------- API 库卡片 ----------
defcreate_api_library_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH, pady=(0, 10))
title_frame = tk.Frame(card, bg=self.colors['primary'])
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="📚 API 库管理", font=self.font_large,
fg='white', bg=self.colors['primary']).pack(side=tk.LEFT, padx=12, pady=8)
self.library_stats = tk.Label(title_frame, text="0 个API", font=self.font_small,
fg='white', bg=self.colors['primary'])
self.library_stats.pack(side=tk.RIGHT, padx=12)
button_frame = tk.Frame(card, bg='white', padx=12, pady=12)
button_frame.pack(fill=tk.X)
buttons = [
("📖 管理API库", self.manage_api_library, self.colors['primary']),
("➕ 添加API", self.add_api_to_library_dialog, self.colors['success']),
("📥 批量添加到调用列表", self.add_library_to_caller, self.colors['secondary']),
]
fortext, command, colorinbuttons:
btn = tk.Button(button_frame, text=text, command=command, bg=color, fg='white',
font=self.font_normal, padx=15, pady=10, relief=tk.FLAT, cursor='hand2',
activebackground=color, activeforeground='white')
btn.pack(fill=tk.X, pady=3)
btn.bind("<Enter>", lambdae, b=btn: b.config(bg=self.lighten_color(b.cget('bg'))))
btn.bind("<Leave>", lambdae, b=btn, c=color: b.config(bg=c))
deflighten_color(self, color):
ifcolor.startswith('#'):
r = min(255, int(color[1:3], 16) +20)
g = min(255, int(color[3:5], 16) +20)
b = min(255, int(color[5:7], 16) +20)
returnf'#{r:02x}{g:02x}{b:02x}'
returncolor
# ---------- URL 管理卡片 ----------
defcreate_url_management_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH)
title_frame = tk.Frame(card, bg=self.colors['dark'])
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="🔗 URL 管理", font=self.font_large,
fg='white', bg=self.colors['dark']).pack(side=tk.LEFT, padx=12, pady=8)
bf = tk.Frame(card, bg='white', padx=12, pady=12)
bf.pack(fill=tk.X)
forrow_configin [
[("➕ 添加URL", self.add_url, self.colors['primary']),
("🗑️ 删除选中", self.delete_selected_urls, self.colors['danger'])],
[("💾 保存到库", self.save_selected_to_library, self.colors['success']),
("🗑️ 清空列表", self.clear_urls, self.colors['warning'])],
[("📥 导入URLs", self.import_urls, self.colors['secondary']),
("📋 下载模板", self.download_template, self.colors['secondary'])],
]:
row = tk.Frame(bf, bg='white')
row.pack(fill=tk.X, pady=3)
fortext, cmd, colorinrow_config:
btn = tk.Button(row, text=text, command=cmd, bg=color, fg='white',
font=self.font_normal, padx=15, pady=10, relief=tk.FLAT,
cursor='hand2', width=12)
side = 'left'ifrow_config.index((text, cmd, color)) == 0else'right'
btn.pack(side=side, padx=2)
# ---------- 调用列表卡片 ----------
defcreate_api_list_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
tf = tk.Frame(card, bg=self.colors['primary'])
tf.pack(fill=tk.X)
tk.Label(tf, text="📋 API 调用列表", font=self.font_large,
fg='white', bg=self.colors['primary']).pack(side=tk.LEFT, padx=12, pady=8)
self.stats_label = tk.Label(tf, text="总计: 0", font=self.font_small,
fg='white', bg=self.colors['primary'])
self.stats_label.pack(side=tk.RIGHT, padx=12)
tf2 = tk.Frame(card, bg='white')
tf2.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
columns = ("序号", "URL", "状态码", "内容长度", "响应时间", "结果")
self.tree = ttk.Treeview(tf2, columns=columns, show='headings',
selectmode='extended', style="Custom.Treeview")
col_widths = {"序号": 60, "URL": 450, "状态码": 80,
"内容长度": 100, "响应时间": 100, "结果": 120}
forcolincolumns:
self.tree.heading(col, text=col, anchor=tk.CENTERifcol!= "URL"elsetk.W)
self.tree.column(col, width=col_widths[col], anchor=tk.CENTERifcol!= "URL"elsetk.W)
vsb = ttk.Scrollbar(tf2, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(tf2, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tf2.grid_rowconfigure(0, weight=1)
tf2.grid_columnconfigure(0, weight=1)
self.tree.bind('<<TreeviewSelect>>', self.show_detail)
self.tree.bind("<Button-3>", self.show_context_menu)
defshow_context_menu(self, event):
menu = tk.Menu(self, tearoff=0)
menu.add_command(label="删除选中", command=self.delete_selected_urls)
menu.add_command(label="保存到API库", command=self.save_selected_to_library)
menu.add_separator()
menu.add_command(label="查看详情", command=lambda: self.show_detail(None))
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
# ---------- 详情 + 操作卡片 ----------
defcreate_detail_action_card(self, parent):
card = tk.Frame(parent, bg='white', relief=tk.FLAT, bd=1,
highlightbackground=self.colors['border'], highlightthickness=1)
card.pack(fill=tk.BOTH)
df = tk.Frame(card, bg='white')
df.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
tk.Label(df, text="📄 响应详情", font=self.font_normal, bg='white').pack(anchor=tk.W, pady=(0, 8))
self.detail_text = scrolledtext.ScrolledText(df, height=6, font=("Consolas", 9),
bg=self.colors['light'], relief=tk.FLAT,
borderwidth=1, highlightbackground=self.colors['border'],
highlightthickness=1)
self.detail_text.pack(fill=tk.BOTH, expand=True)
af = tk.Frame(card, bg='white')
af.pack(fill=tk.X, padx=10, pady=(0, 10))
self.call_button = tk.Button(af, text="▶️ 调用APIs", command=self.call_apis,
bg=self.colors['success'], fg='white',
font=self.font_normal, padx=25, pady=12,
relief=tk.FLAT, cursor='hand2')
self.call_button.pack(side=tk.LEFT, padx=5)
self.quick_save_button = tk.Button(af, text="💾 一键保存", command=self.quick_save,
bg=self.colors['secondary'], fg='white',
font=self.font_normal, padx=25, pady=12,
relief=tk.FLAT, cursor='hand2', state=tk.DISABLED)
self.quick_save_button.pack(side=tk.LEFT, padx=5)
self.export_button = tk.Button(af, text="📤 导出结果", command=self.export_results,
bg=self.colors['primary'], fg='white',
font=self.font_normal, padx=25, pady=12,
relief=tk.FLAT, cursor='hand2', state=tk.DISABLED)
self.export_button.pack(side=tk.LEFT, padx=5)
self.progress = ttk.Progressbar(af, mode='determinate',
style="Custom.Horizontal.TProgressbar", length=200)
self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=10)
defconfigure_styles(self):
style = ttk.Style()
if'clam'instyle.theme_names():
style.theme_use('clam')
style.configure("Custom.Treeview", background=self.colors['light'],
foreground=self.colors['dark'], rowheight=28,
fieldbackground=self.colors['light'], font=self.font_small, borderwidth=0)
style.configure("Custom.Treeview.Heading", background=self.colors['secondary'],
foreground='white', relief=tk.FLAT,
font=("微软雅黑", 9, "bold"), borderwidth=1)
style.configure("Custom.Horizontal.TProgressbar", troughcolor=self.colors['light'],
background=self.colors['success'], thickness=20, borderwidth=0)
self.tree.tag_configure('success', background='#e8f8f0')
self.tree.tag_configure('warning', background='#fdf3e0')
self.tree.tag_configure('error', background='#fdeaea')
# ---------- API 库持久化 ----------
defload_api_library(self):
library_file = os.path.join(_get_module_data_dir(), "api_library.json")
ifnotos.path.exists(library_file):
return []
try:
withopen(library_file, 'r', encoding='utf-8') asf:
library = json.load(f)
forapiinlibrary:
if'id'notinapi:
api['id'] = str(uuid.uuid4())
returnlibrary
exceptExceptionase:
print(f"加载API库失败: {e}")
return []
defsave_api_library(self):
os.makedirs(_get_module_data_dir(), exist_ok=True)
library_file = os.path.join(_get_module_data_dir(), "api_library.json")
try:
withopen(library_file, 'w', encoding='utf-8') asf:
json.dump(self.api_library, f, ensure_ascii=False, indent=2)
returnTrue
exceptExceptionase:
messagebox.showerror("错误", f"保存API库时出错: {e}")
returnFalse
defupdate_library_stats(self):
ifself.library_stats:
self.library_stats.config(text=f"{len(self.api_library)} 个API")
defupdate_stats(self):
self.stats_label.config(text=f"总计: {len(self.urls)}")
defupdate_status(self, message):
ifself.main_appandhasattr(self.main_app, 'update_status'):
self.main_app.update_status(message)
else:
print(f"状态: {message}")
# ---------- API 库管理对话框 ----------
defmanage_api_library(self):
library_window = tk.Toplevel(self)
library_window.title("API库管理")
library_window.geometry("800x500")
library_window.configure(bg='white')
library_window.transient(self)
library_window.grab_set()
main_frame = tk.Frame(library_window, bg='white', padx=20, pady=20)
main_frame.pack(fill=tk.BOTH, expand=True)
tk.Label(main_frame, text="📚 API 库管理", font=self.font_title,
bg='white', fg=self.colors['primary']).pack(side=tk.LEFT, pady=(0, 15))
# 搜索框
search_frame = tk.Frame(main_frame, bg='white')
search_frame.pack(side=tk.RIGHT, pady=(0, 15))
tk.Label(search_frame, text="搜索:", font=self.font_small,
bg='white').pack(side=tk.LEFT, padx=(0, 5))
search_var = tk.StringVar()
tk.Entry(search_frame, textvariable=search_var, font=self.font_small,
width=25, relief=tk.FLAT, bg=self.colors['light']).pack(side=tk.LEFT)
tc = tk.Frame(main_frame, bg='white')
tc.pack(fill=tk.BOTH, expand=True)
columns = ("序号", "名称", "URL", "添加时间")
tree = ttk.Treeview(tc, columns=columns, show='headings',
selectmode='extended', style="Custom.Treeview")
forcol, w, ancinzip(columns, [60, 150, 400, 150],
[tk.CENTER, tk.W, tk.W, tk.CENTER]):
tree.heading(col, text=col, anchor=anc)
tree.column(col, width=w, anchor=anc)
foridx, apiinenumerate(self.api_library, 1):
tree.insert("", tk.END, values=(idx, api.get('name', '未命名'),
api.get('url', ''), api.get('added_time', '')),
tags=(api.get('id', ''),))
vsb = ttk.Scrollbar(tc, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
tc.grid_rowconfigure(0, weight=1)
tc.grid_columnconfigure(0, weight=1)
defsearch_api():
query = search_var.get().lower()
foritemintree.get_children():
tree.delete(item)
foridx, apiinenumerate(self.api_library, 1):
name = api.get('name', '').lower()
url_ = api.get('url', '').lower()
ifqueryinnameorqueryinurl_:
tree.insert("", tk.END, values=(idx, api.get('name', '未命名'),
api.get('url', ''), api.get('added_time', '')),
tags=(api.get('id', ''),))
search_var.trace("w", lambda*a: search_api())
defdelete_selected():
sel = tree.selection()
ifnotsel:
messagebox.showwarning("警告", "请选择要删除的API"); return
ifnotmessagebox.askyesno("确认", f"确定删除选中的 {len(sel)} 个API吗?"):
return
deleted_ids = [tree.item(i)['tags'][0] foriinsel]
self.api_library = [aforainself.api_libraryifa.get('id') notindeleted_ids]
ifself.save_api_library():
foriteminsel:
tree.delete(item)
foridx, iteminenumerate(tree.get_children(), 1):
vals = list(tree.item(item)['values']); vals[0] = idx
tree.item(item, values=vals)
self.update_library_stats()
defadd_to_caller():
sel = tree.selection()
ifnotsel:
messagebox.showwarning("警告", "请选择要添加的API"); return
added = 0
foriteminsel:
vals = tree.item(item)['values']
url_ = vals[2] iflen(vals) >= 3else''
ifurl_andurl_notinself.urls:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
added += 1
ifadded>0:
self.update_stats()
messagebox.showinfo("成功", f"已添加 {added} 个API到调用列表")
bf = tk.Frame(main_frame, bg='white')
bf.pack(fill=tk.X, pady=(15, 0))
tk.Button(bf, text="🗑️ 删除选中", command=delete_selected,
bg=self.colors['danger'], fg='white', font=self.font_normal,
padx=20, pady=8, relief=tk.FLAT, cursor='hand2').pack(side=tk.LEFT, padx=5)
tk.Button(bf, text="➕ 添加到调用列表", command=add_to_caller,
bg=self.colors['success'], fg='white', font=self.font_normal,
padx=20, pady=8, relief=tk.FLAT, cursor='hand2').pack(side=tk.LEFT, padx=5)
tk.Button(bf, text="关闭", command=library_window.destroy,
bg=self.colors['dark'], fg='white', font=self.font_normal,
padx=20, pady=8, relief=tk.FLAT, cursor='hand2').pack(side=tk.RIGHT)
defadd_api_to_library_dialog(self):
dialog = tk.Toplevel(self)
dialog.title("添加API")
dialog.geometry("500x300"); dialog.configure(bg='white')
dialog.transient(self); dialog.grab_set()
mf = tk.Frame(dialog, bg='white', padx=30, pady=30); mf.pack(fill=tk.BOTH, expand=True)
tk.Label(mf, text="➕ 添加新API", font=self.font_title, bg='white',
fg=self.colors['primary']).pack(anchor=tk.W, pady=(0, 20))
tk.Label(mf, text="API名称:", font=self.font_normal, bg='white').pack(anchor=tk.W)
name_var = tk.StringVar()
tk.Entry(mf, textvariable=name_var, font=self.font_normal,
bg=self.colors['light'], relief=tk.FLAT, width=40).pack(fill=tk.X, pady=(0, 15))
tk.Label(mf, text="API URL:", font=self.font_normal, bg='white').pack(anchor=tk.W)
url_var = tk.StringVar()
tk.Entry(mf, textvariable=url_var, font=self.font_normal,
bg=self.colors['light'], relief=tk.FLAT, width=40).pack(fill=tk.X, pady=(0, 20))
defsave_api():
name = name_var.get().strip(); url_ = url_var.get().strip()
ifnoturl_:
messagebox.showwarning("警告", "请输入URL"); return
ifnotname:
name = f"API_{len(self.api_library) + 1}"
ifany(a.get('url') == url_forainself.api_library):
messagebox.showwarning("警告", "URL已存在"); return
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
ifself.save_api_library():
self.update_library_stats()
messagebox.showinfo("成功", "API已添加")
dialog.destroy()
bf = tk.Frame(mf, bg='white'); bf.pack(fill=tk.X)
tk.Button(bf, text="保存", command=save_api, bg=self.colors['success'],
fg='white', font=self.font_normal, padx=25, pady=8,
relief=tk.FLAT, cursor='hand2').pack(side=tk.LEFT)
tk.Button(bf, text="取消", command=dialog.destroy, bg=self.colors['dark'],
fg='white', font=self.font_normal, padx=25, pady=8,
relief=tk.FLAT, cursor='hand2').pack(side=tk.RIGHT)
defadd_library_to_caller(self):
ifnotself.api_library:
messagebox.showinfo("提示", "API库为空"); return
ifmessagebox.askyesno("确认",
f"确定要将所有 {len(self.api_library)} 个API添加到调用列表吗?"):
added = 0
forapiinself.api_library:
url_ = api.get('url')
ifurl_andurl_notinself.urls:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
added += 1
ifadded>0:
self.update_stats()
messagebox.showinfo("成功", f"已添加 {added} 个API到调用列表")
defsave_selected_to_library(self):
sel = self.tree.selection()
ifnotsel:
messagebox.showwarning("警告", "请选择要保存的URL"); return
foriteminsel:
vals = self.tree.item(item)['values']
url_ = vals[1] iflen(vals) >= 2else''
ifany(a.get('url') == url_forainself.api_library):
continue
name = simpledialog.askstring("API名称", f"为URL设置名称:\n{url_}", parent=self)
ifname:
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
ifself.save_api_library():
self.update_library_stats()
messagebox.showinfo("成功", "已保存到API库")
# ---------- URL 操作 ----------
defadd_url(self):
url_ = simpledialog.askstring("添加URL", "请输入API URL:", parent=self)
ifurl_:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
self.update_stats()
ifmessagebox.askyesno("保存到API库", "是否将此URL保存到API库?"):
name = simpledialog.askstring("API名称", f"为URL设置名称:\n{url_}", parent=self)
ifnameandnotany(a.get('url') == url_forainself.api_library):
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
self.save_api_library(); self.update_library_stats()
defdelete_selected_urls(self):
sel = list(self.tree.selection())
ifnotsel:
messagebox.showwarning("警告", "请选择要删除的URL"); return
ifnotmessagebox.askyesno("确认", f"确定删除选中的 {len(sel)} 个URL吗?"):
return
sel.reverse()
deleted_indices = []
foriteminsel:
vals = self.tree.item(item)['values']
url_ = vals[1] iflen(vals) >= 2else''
ifurl_inself.urls:
deleted_indices.append(self.urls.index(url_))
self.urls.remove(url_)
self.tree.delete(item)
ifself.results:
foridxinsorted(deleted_indices, reverse=True):
ifidx<len(self.results):
self.results.pop(idx)
self.renumber_items(); self.update_stats()
ifnotself.results:
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)
defclear_urls(self):
ifnotself.urls: return
ifmessagebox.askyesno("确认", "确定要清空所有URL吗?"):
self.urls.clear(); self.results.clear()
foriteminself.tree.get_children(): self.tree.delete(item)
self.detail_text.delete(1.0, tk.END)
self.update_stats()
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)
defrenumber_items(self):
fori, iteminenumerate(self.tree.get_children(), 1):
vals = list(self.tree.item(item)['values'])
ifvals: vals[0] = i
self.tree.item(item, values=vals)
# ---------- 核心:批量调用 ----------
defcall_apis(self):
ifnotself.urls:
messagebox.showwarning("警告", "没有URL可调用"); return
self.call_button.config(state=tk.DISABLED)
self.quick_save_button.config(state=tk.DISABLED)
self.export_button.config(state=tk.DISABLED)
self.progress['value'] = 0
self.progress['maximum'] = len(self.urls)
thread = threading.Thread(target=self._call_apis_threaded, daemon=True)
thread.start()
def_call_apis_threaded(self):
self.results = []
fori, url_inenumerate(self.urls):
try:
self.after(0, self.update_status, f"正在调用: {url_[:40]}...")
start = datetime.now()
response = requests.get(url_, timeout=10)
elapsed = (datetime.now() -start).total_seconds()
code = response.status_code
length = len(response.content)
if200<= code<300:
result_text, tag = "✅ 成功", 'success'
elif400<= code<500:
result_text, tag = "⚠️ 客户端错误", 'warning'
elif500<= code<600:
result_text, tag = "⚠️ 服务器错误", 'warning'
else:
result_text, tag = "ℹ️ 其他状态", ''
self.after(0, self.update_tree_item, i, url_, code, length, elapsed, result_text, tag)
self.results.append({"序号": i+1, "URL": url_, "状态码": code,
"内容长度": length, "响应时间(秒)": elapsed, "结果": result_text})
exceptrequests.RequestExceptionase:
error_msg = str(e)
self.after(0, self.update_tree_item, i, url_, "❌ 错误", error_msg, 0,
"❌ 请求失败", 'error')
self.results.append({"序号": i+1, "URL": url_, "状态码": "错误",
"内容长度": error_msg, "响应时间(秒)": 0, "结果": "请求失败"})
self.after(0, self.progress.step, 1)
self.after(0, self.call_complete)
defupdate_tree_item(self, index, url_, code, length, elapsed, text, tag=''):
ifindex<len(self.tree.get_children()):
item_id = self.tree.get_children()[index]
self.tree.item(item_id, values=(
index+1, url_, code, length,
f"{elapsed:.3f}s"ifisinstance(elapsed, (int, float)) elseelapsed,
text), tags=(tag,) iftagelse ())
defcall_complete(self):
self.call_button.config(state=tk.NORMAL)
ifself.resultsandlen(self.results) >0:
self.quick_save_button.config(state=tk.NORMAL)
self.export_button.config(state=tk.NORMAL)
messagebox.showinfo("完成", f"API调用完成!\n共处理 {len(self.urls)} 个API")
# ---------- 详情 ----------
defshow_detail(self, event):
sel = self.tree.selection()
ifnotsel: return
vals = self.tree.item(sel[0])['values']
detail = f"📌 URL: {vals[1]}\n📊 状态码: {vals[2]}\n📏 内容长度: {vals[3]}"
iflen(vals) >4: detail += f"\n⏱️ 响应时间: {vals[4]}"
iflen(vals) >5: detail += f"\n✅ 结果: {vals[5]}"
self.detail_text.delete(1.0, tk.END)
self.detail_text.insert(1.0, detail)
# ---------- 导入导出 ----------
defdownload_template(self):
try:
df = pd.DataFrame(columns=["URL", "名称", "备注"])
filename = filedialog.asksaveasfilename(
defaultextension=".xlsx", initialfile="api_template.xlsx",
title="保存模板文件",
filetypes=[("Excel文件", "*.xlsx"), ("CSV文件", "*.csv")])
iffilename:
iffilename.endswith('.xlsx'):
df.to_excel(filename, index=False)
else:
df.to_csv(filename, index=False, encoding='utf-8-sig')
messagebox.showinfo("成功", f"模板已保存到 {filename}")
exceptExceptionase:
messagebox.showerror("错误", f"下载模板时出错: {e}")
defimport_urls(self):
try:
filename = filedialog.askopenfilename(
title="选择文件",
filetypes=[("Excel文件", "*.xlsx"), ("CSV文件", "*.csv"), ("所有文件", "*.*")])
ifnotfilename: return
iffilename.endswith('.xlsx'):
df = pd.read_excel(filename)
else:
df = None
forencin ['utf-8', 'gbk', 'latin1']:
try:
df = pd.read_csv(filename, encoding=enc); break
exceptException: pass
ifdfisNone: raiseException("无法解析文件编码")
url_col = None; name_col = None
forcolindf.columns:
cl = col.lower()
if'url'incl: url_col = col
elif'名称'inclor'name'incl: name_col = col
ifurl_colisNoneandlen(df.columns) >0: url_col = df.columns[0]
ifurl_colisNone:
messagebox.showerror("错误", "未找到URL列"); return
save_to_lib = False
ifname_colandname_colindf.columns:
save_to_lib = messagebox.askyesno(
"保存到API库",
f"检测到名称列('{name_col}'),是否将导入的URL保存到API库?")
added = 0; saved = 0
for_, rowindf.iterrows():
url_ = str(row[url_col]).strip() ifnotpd.isna(row[url_col]) else''
ifurl_andurl_notinself.urls:
self.urls.append(url_)
self.tree.insert("", tk.END, values=(len(self.urls), url_, "", "", "", ""))
added += 1
ifsave_to_libandurl_andnotany(a.get('url') == url_forainself.api_library):
name = str(row[name_col]).strip() ifnotpd.isna(row[name_col]) else''
ifnotname: name = f"API_{len(self.api_library) + saved + 1}"
self.api_library.append({
'id': str(uuid.uuid4()), 'name': name, 'url': url_,
'added_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
saved += 1
ifsaved:
self.save_api_library(); self.update_library_stats()
self.update_stats()
ifadded>0:
messagebox.showinfo("成功", f"导入 {added} 个URL"+
(f",{saved} 个已保存到API库"ifsavedelse""))
exceptExceptionase:
messagebox.showerror("错误", f"导入URLs时出错: {e}")
defexport_results(self):
ifnotself.results:
messagebox.showwarning("警告", "没有可导出的结果,请先调用API"); return
try:
filename = filedialog.asksaveasfilename(
defaultextension=".xlsx",
initialfile=f"api_results_{len(self.results)}_"
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx",
title="保存结果文件",
filetypes=[("Excel文件", "*.xlsx"), ("CSV文件", "*.csv")])
iffilename:
df = pd.DataFrame(self.results)
iffilename.endswith('.xlsx'):
df.to_excel(filename, index=False)
else:
df.to_csv(filename, index=False, encoding='utf-8-sig')
messagebox.showinfo("成功", f"结果已保存到 {filename}")
exceptExceptionase:
messagebox.showerror("错误", f"导出结果时出错: {e}")
defquick_save(self):
ifnotself.results:
messagebox.showwarning("警告", "没有可保存的结果,请先调用API"); return
try:
results_dir = os.path.join(_get_module_data_dir(), 'api_results')
os.makedirs(results_dir, exist_ok=True)
filename = f"api_results_{len(self.results)}_"\
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
filepath = os.path.join(results_dir, filename)
pd.DataFrame(self.results).to_excel(filepath, index=False)
ifmessagebox.askyesno("打开文件夹", "是否打开结果文件夹?"):
os.startfile(results_dir)
exceptExceptionase:
messagebox.showerror("错误", f"一键保存时出错: {e}")
# ---------- 独立运行版 ----------
classAPICallerApp:
def__init__(self):
self.root = tk.Tk()
self.root.title("API 调用工具 v3.0")
self.root.geometry("1200x800")
self.frame = APICallerFrame(self.root)
self.frame.set_dependencies(main_app=self)
self.frame.pack(fill=tk.BOTH, expand=True)
self.status_bar = tk.Label(self.root, text="就绪", bd=1,
relief=tk.SUNKEN, anchor=tk.W)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
defupdate_status(self, message):
self.status_bar.config(text=message)
self.root.update_idletasks()
defrun(self):
self.root.mainloop()
if__name__ == "__main__":
APICallerApp().run()
十四、写在最后
这个工具值得说的不是 1300 行代码,而是开头那两个"刻意":刻意串行不做并发、刻意只做 GET 不做复杂请求。砍完之后,界面、并发模型、数据结构全都自然变简单——值班同学双击打开、导入 Excel、点一下按钮、拿到一份可归档的报告,整条链路没有学习成本。
今天刚踩的打包坑也是这个思路的延伸:绝大多数小工具只依赖标准库 → EXCLUDE_MODULES 排除大型库减小体积,但偏偏有那么一两个工具(比如这个)真的需要非标准库 → 加一个 FORCED_INCLUDE 字典让它自己声明放行哪些模块,既不破坏其他工具的打包体积,又能正确打包这个特例。
做内部工具尤其要警惕"功能越多越强"的陷阱:一个按钮位置都不用找、双击就出报告的流水线,对值班同学来说比一百个高级功能有用得多。















































































