Files
2026-08-05 17:25:11 +08:00

1556 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
数字资产管理器 v3 — Tkinter 桌面版
==================================
三大资产分类:项目资产 / 工作日志 / 训练语料
六个标签页:导入 | 项目 | 日志 | 语料 | 搜索 | 导出 (MD + JSONL)
"""
import json
import os
import sys
import threading
import tkinter as tk
from datetime import datetime
from tkinter import filedialog, messagebox, ttk
def get_app_dir():
"""获取应用根目录,兼容 PyInstaller 打包和源码运行。
PyInstaller 打包后 sys.frozen=TrueEXE路径=sys.executable 所在目录;
源码运行时,路径=脚本文件所在目录。
"""
return (
os.path.dirname(sys.executable)
if getattr(sys, "frozen", False)
else os.path.dirname(os.path.abspath(__file__))
)
APP_DIR = get_app_dir()
sys.path.insert(0, APP_DIR)
import digital_assets_manager as dam
dam.DB_PATH = os.path.join(APP_DIR, "digital_assets.db")
# ═══════════════════════ 数据库工具 ═══════════════════════
def db_conn():
"""获取数据库连接(快捷方式,调用 digital_assets_manager.get_conn)。"""
return dam.get_conn()
def project_names():
"""获取所有项目名称列表,按更新时间降序排列。
Returns:
list[str]: 项目名列表,供下拉框使用
"""
conn = db_conn()
try:
return [
r["name"]
for r in conn.execute(
"SELECT name FROM projects ORDER BY updated_at DESC"
).fetchall()
]
finally:
conn.close()
# ═══════════════════════ 主窗口 ═══════════════════════
class App(tk.Tk):
"""主窗口,管理导航和6个功能面板的切换。
面板索引:
0=导入归档 | 1=项目资产 | 2=工作日志 |
3=训练语料 | 4=全局搜索 | 5=导出报告
左侧深蓝导航栏 + 右侧功能面板区。切换面板时刷新数据并高亮当前按钮。
"""
def __init__(self):
super().__init__()
self.title("数字资产管理器 — 资料封存与速查")
self.geometry("1060x680")
self.minsize(880, 520)
dam.init_db()
# ── 生成窗口图标 ──
try:
icon = tk.PhotoImage(width=32, height=32)
for y in range(32):
for x in range(32):
# 渐变蓝色方块,中心金色D字简标
r, g, b = 30 + x * 3, 60 + y * 3, 140
if 8 <= x <= 24 and 8 <= y <= 24:
r, g, b = 220, 180, 40 # 金色中心
if ((x - 16) ** 2 + (y - 16) ** 2) > 144:
r, g, b = r // 3, g // 3, b // 3 # 圆角效果
icon.put(f"#{r:02x}{g:02x}{b:02x}", (x, y))
self.iconphoto(True, icon)
except Exception:
pass
# ── 全局样式 ──
s = ttk.Style()
try:
s.theme_use("vista")
except Exception:
s.theme_use("clam")
# 配色
BG = "#f5f6f8"
ACCENT = "#3b6fb6"
ACCENT_DARK = "#28518a"
s.configure("TFrame", background=BG)
s.configure("TLabel", background=BG, font=("Microsoft YaHei", 9))
s.configure("TLabelframe", background=BG, font=("Microsoft YaHei", 9))
s.configure(
"TLabelframe.Label", background=BG, font=("Microsoft YaHei", 9, "bold")
)
s.configure("TButton", font=("Microsoft YaHei", 9), padding=(12, 4))
s.configure(
"Accent.TButton",
background=ACCENT,
foreground="white",
font=("Microsoft YaHei", 9, "bold"),
)
# 导航按钮样式
s.configure(
"Nav.TButton", font=("Microsoft YaHei", 10), padding=(8, 6), anchor=tk.W
)
s.map("Nav.TButton", background=[("active", "#dee8f5")])
# 标题样式
s.configure(
"Title.TLabel",
font=("Microsoft YaHei", 14, "bold"),
foreground=ACCENT_DARK,
background=BG,
)
s.configure(
"Subtitle.TLabel",
font=("Microsoft YaHei", 9),
foreground="#666",
background=BG,
)
s.configure(
"Card.TLabelframe", background="white", relief=tk.RIDGE, borderwidth=1
)
s.configure(
"Card.TLabelframe.Label",
background="white",
font=("Microsoft YaHei", 9, "bold"),
foreground=ACCENT_DARK,
)
self.configure(bg=BG)
self.bg = BG
# ── 左侧导航 ──
nav = tk.Frame(self, bg="#283548", width=140)
nav.pack(side=tk.LEFT, fill=tk.Y)
nav.pack_propagate(False)
# 导航按钮
tabs = [
(" 📥 导入归档", 0),
(" 📁 项目资产", 1),
(" 📔 工作日志", 2),
(" 📚 训练语料", 3),
(" 🔍 全局搜索", 4),
(" 📤 导出报告", 5),
]
self.nav_btns = []
for label, idx in tabs:
btn = tk.Button(
nav,
text=label,
font=("Microsoft YaHei", 10),
bg="#283548",
fg="#b0c4de",
activebackground="#3b6fb6",
activeforeground="white",
bd=0,
padx=12,
pady=8,
anchor=tk.W,
cursor="hand2",
relief=tk.FLAT,
command=lambda i=idx: self.switch(i),
)
btn.pack(fill=tk.X)
btn.bind(
"<Enter>",
lambda e, b=btn: b.configure(
bg="#344863" if not getattr(b, "_active", False) else "#3b6fb6"
),
)
btn.bind(
"<Leave>",
lambda e, b=btn: b.configure(
bg="#3b6fb6" if getattr(b, "_active", False) else "#283548"
),
)
self.nav_btns.append(btn)
# 底部:Logo + 版本号
tk.Label(nav, text="v3.0", font=("", 8), bg="#283548", fg="#566a80").pack(
side=tk.BOTTOM, pady=2
)
logo_frame = tk.Frame(nav, bg="#1e2a3a", height=64)
logo_frame.pack(side=tk.BOTTOM, fill=tk.X)
logo_frame.pack_propagate(False)
tk.Label(logo_frame, text="📦", font=("", 20), bg="#1e2a3a", fg="white").pack(
pady=(10, 0)
)
tk.Label(
logo_frame,
text="数字资产",
font=("Microsoft YaHei", 8, "bold"),
bg="#1e2a3a",
fg="#8eb4e3",
).pack()
# ── 右侧内容区 ──
self.content = tk.Frame(self, bg=BG)
self.content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.panels = [
ImportPanel(self.content, BG),
ProjectPanel(self.content, BG),
LogPanel(self.content, BG),
CorpusPanel(self.content, BG),
SearchPanel(self.content, BG),
ExportPanel(self.content, BG),
]
self.switch(0)
def switch(self, idx):
"""切换到指定索引的面板。
同时更新导航按钮高亮状态(_active 标记)并刷新目标面板数据。
非目标面板从布局中移除(pack_forget)以释放资源。
"""
for i, btn in enumerate(self.nav_btns):
if i == idx:
btn.configure(bg="#3b6fb6", fg="white")
btn._active = True
else:
btn.configure(bg="#283548", fg="#b0c4de")
btn._active = False
for i, p in enumerate(self.panels):
if i == idx:
p.pack(fill=tk.BOTH, expand=True)
p.refresh()
else:
p.pack_forget()
# ═══════════════════════ 1. 导入(三子系统) ═══════════════════════
class ImportPanel(ttk.Frame):
"""导入归档面板:三个独立导入入口 + 实时日志 + 历史记录。
三入口分别对应: 项目资产 / 工作日志 / 训练语料。
每个入口有独立的文件夹选择、递归开关、项目名(仅项目资产)、开始导入按钮。
导入在后台线程执行数据库操作,通过 self.after() 安全更新UI。
"""
def __init__(self, parent, bg="#f5f6f8"):
super().__init__(parent)
self.bg = bg
self.build()
def _make_section(self, parent, icon, title, desc):
"""构建一个导入子面板。
一行布局: [文件夹标签] [路径输入框] [浏览] [递归勾选] [项目名(可选)] [开始导入]
Returns:
StringVar: 文件夹路径变量
"""
"""构建导入子面板:文件夹 [输入框] [浏览] [开始导入] 一行搞定"""
f = ttk.LabelFrame(parent, text=f"{icon} {title}", padding=6)
f.pack(fill=tk.X, pady=3, padx=4)
ttk.Label(f, text=desc, foreground="gray").pack(anchor=tk.W, pady=(0, 4))
r1 = ttk.Frame(f)
r1.pack(fill=tk.X)
ttk.Label(r1, text="文件夹:").pack(side=tk.LEFT)
dir_var = tk.StringVar()
ttk.Entry(r1, textvariable=dir_var, width=42).pack(side=tk.LEFT, padx=4)
ttk.Button(
r1,
text="选文件夹",
width=8,
command=lambda: dir_var.set(filedialog.askdirectory() or dir_var.get()),
).pack(side=tk.LEFT, padx=(0, 4))
# 文件类型过滤(训练语料仅 .jsonl,其它类型放宽)
if title == "训练语料":
_ftypes = [("JSONL 语料", "*.jsonl"), ("所有文件", "*.*")]
else:
_ftypes = [
("文本/代码", "*.md;*.txt;*.py;*.c;*.h;*.cpp;*.hpp;*.json;*.csv;*.log;*.yaml;*.yml;*.ini;*.cfg;*.toml;*.jsonl"),
("二进制/文档/其它", "*.pdf;*.docx;*.xlsx;*.pptx;*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.svg;*.bin;*.hex;*.elf;*.uf2;*.sch;*.brd;*.kicad_pcb;*.kicad_sch;*.zip;*.rar;*.7z;*.gz"),
("所有文件", "*.*"),
]
ttk.Button(
r1,
text="选文件",
width=6,
command=lambda: dir_var.set(
filedialog.askopenfilename(filetypes=_ftypes) or dir_var.get()
),
).pack(side=tk.LEFT, padx=(0, 4))
rec_var = tk.BooleanVar(value=True)
proj_var = None
btn = ttk.Button(r1, text="开始导入")
btn.pack(side=tk.RIGHT, padx=(4, 0))
ttk.Checkbutton(r1, text="递归", variable=rec_var).pack(side=tk.RIGHT, padx=4)
if title == "项目资产":
proj_var = tk.StringVar()
ttk.Entry(r1, textvariable=proj_var, width=12).pack(side=tk.RIGHT)
ttk.Label(r1, text="项目名:").pack(side=tk.RIGHT, padx=2)
btn.configure(
command=lambda dv=dir_var, rv=rec_var, pv=proj_var, tw=title, b=btn: (
self._do_import(
dv.get().strip(), rv.get(), pv.get().strip() if pv else None, tw, b
)
)
)
# 登记该导入区的路径框,便于导入完成后清空
self.path_vars[title] = dir_var
return dir_var
def build(self):
"""构建导入面板UI:标题 + 三个导入区 + 实时日志 + 导入历史表格。"""
tk.Label(
self,
text="导入归档",
font=("Microsoft YaHei", 14, "bold"),
fg="#28518a",
bg=self.bg,
).pack(pady=(12, 0), anchor=tk.W)
tk.Label(
self,
text="三种资产分开导入,互不混淆",
font=("Microsoft YaHei", 9),
fg="#888",
bg=self.bg,
).pack(anchor=tk.W)
# 共享日志区(先创建,三个导入区共用)
lf = ttk.LabelFrame(self, text="实时日志", padding=4)
lf.pack(fill=tk.BOTH, expand=False, pady=6, padx=4)
self.log = tk.Text(lf, height=6, font=("Consolas", 9), wrap=tk.WORD)
self.log.pack(fill=tk.BOTH, side=tk.LEFT)
ttk.Scrollbar(lf, command=self.log.yview).pack(fill=tk.Y, side=tk.RIGHT)
# 路径框索引(按 import_type 存,导入完成后清空对应框)
self.path_vars = {}
# 三个子面板(导入入口)
self._make_section(
self,
"📁",
"项目资产",
"导入整个项目文件夹,子文件夹=项目,所有文件归入对应项目",
)
self._make_section(
self, "📔", "工作日志", "导入工作日志文件夹,所有文件归入「工作日志」时间线"
)
self._make_section(
self, "📚", "训练语料", "导入 .jsonl Q&A 文件,自动解析存入语料库"
)
# 历史记录区
hf = ttk.LabelFrame(self, text="导入历史(按天)", padding=4)
hf.pack(fill=tk.BOTH, expand=True, padx=4)
cols = ("日期", "类型", "项目", "文件数", "摘要", "文件夹路径")
self.hist = ttk.Treeview(hf, columns=cols, show="headings", height=8)
w = {
"日期": 85,
"类型": 65,
"项目": 100,
"文件数": 50,
"摘要": 150,
"文件夹路径": 250,
}
for c in cols:
self.hist.heading(c, text=c)
self.hist.column(c, width=w.get(c, 70), anchor=tk.W)
self.hist.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
ttk.Scrollbar(hf, command=self.hist.yview).pack(fill=tk.Y, side=tk.RIGHT)
def _show_history(self):
"""从数据库加载并显示导入历史"""
self.hist.delete(*self.hist.get_children())
try:
conn = dam.get_conn()
rows = conn.execute(
"SELECT import_date, import_type, project_name, file_count, summary, folder_path FROM import_history ORDER BY created_at DESC LIMIT 100"
).fetchall()
conn.close()
for r in rows:
self.hist.insert(
"",
tk.END,
values=(
r["import_date"],
r["import_type"],
r["project_name"],
r["file_count"],
r["summary"],
r["folder_path"],
),
)
except Exception:
pass
def refresh(self):
self._show_history()
def _save_history(
self, import_type, folder_path, project_name, file_count, summary
):
"""写入一条导入历史记录到 import_history 表,记录日期/类型/源路径/项目/文件数/摘要。"""
try:
conn = dam.get_conn()
conn.execute(
"INSERT INTO import_history (import_date, import_type, folder_path, project_name, file_count, summary, created_at) VALUES (?,?,?,?,?,?,?)",
(
datetime.now().strftime("%Y-%m-%d"),
import_type,
folder_path,
project_name or "",
file_count,
summary,
dam.now(),
),
)
conn.commit()
conn.close()
except Exception:
pass
def _log(self, txt):
"""主线程安全写日志:将文本追加到实时日志区并滚动到底部。
通过 self.after() 从后台线程调度回主线程执行,避免 Tkinter 线程安全问题。
"""
self.log.insert(tk.END, txt + "\n")
self.log.see(tk.END)
def _safe_import_files(
self, dir_path, recursive, project_name, import_type, log_line, done
):
"""带异常保护的文件导入包装器,确保按钮总能恢复。"""
try:
self._import_files(
dir_path, recursive, project_name, import_type, log_line, done
)
except Exception as e:
log_line(f"[ERR] 导入异常: {e}")
done()
def _safe_import_corpus(self, dir_path, log_line, done):
"""带异常保护的语料导入包装器,确保按钮总能恢复。"""
try:
self._import_corpus(dir_path, log_line, done)
except Exception as e:
log_line(f"[ERR] 语料导入异常: {e}")
done()
def _on_import_done(self):
"""导入完成回调(主线程安全,由 self.after() 调度)。"""
s = getattr(self, "stats", {"imported": 0, "skipped": 0, "project": "", "note": ""})
it = getattr(self, "_current_import_type", "")
summary = s.get("note", f"成功{s['imported']}条, 跳过{s['skipped']}条")
# 恢复按钮
btn = getattr(self, "_current_btn", None)
if btn:
btn.configure(state="normal", text="开始导入")
self.log.insert(tk.END, f"\n[{it}] {summary}\n")
self._save_history(
it,
getattr(self, "_current_dir", ""),
s.get("project", ""),
s["imported"],
summary,
)
self._show_history()
# 导入完成清空对应路径框,避免残留旧路径误操作
pv = getattr(self, "path_vars", {}).get(it)
if pv is not None:
pv.set("")
def _do_import(self, dir_path, recursive, project_name, import_type, btn):
"""执行导入:验证路径 → 启动后台线程 → 实时日志 → 完成回调。
Args:
dir_path: 源文件夹路径
recursive: 是否递归子目录
project_name: 项目名(None=自动检测)
import_type: "项目资产"/"工作日志"/"训练语料"
btn: 触发按钮(导入完成后恢复状态)
"""
if not dir_path:
messagebox.showwarning("提示", "请选择文件夹或文件")
return
if not (os.path.isdir(dir_path) or os.path.isfile(dir_path)):
messagebox.showwarning("提示", "路径不存在")
return
self.log.delete("1.0", tk.END)
self.log.insert(tk.END, f"[{import_type}] 开始导入: {dir_path}\n{'=' * 50}\n")
btn.configure(state="disabled", text="导入中...")
self.stats = {"imported": 0, "skipped": 0, "project": ""}
self._current_dir = dir_path
self._current_import_type = import_type
self._current_btn = btn
def log_line(t):
"""线程安全:通过 after() 调度到主线程写日志"""
self.after(0, self._log, t)
def done():
"""线程安全:通过 after() 调度到主线程完成导入"""
self.stats["note"] = f"成功{self.stats['imported']}条, 跳过{self.stats['skipped']}条"
self.after(0, self._on_import_done)
if import_type == "训练语料":
threading.Thread(
target=self._safe_import_corpus, args=(dir_path, log_line, done), daemon=True
).start()
else:
threading.Thread(
target=self._safe_import_files,
args=(dir_path, recursive, project_name, import_type, log_line, done),
daemon=True,
).start()
def _import_files(
self, dir_path, recursive, project_name, import_type, log_line, done
):
"""后台线程执行文件导入:收集文件→确保项目存在→逐文件读内容→写入 knowledge_cards。
二进制文件只存元数据(路径+类型标签),文本文件读全文(超10000字截断并告警)。
import_type 控制 category: "工作日志"→归入工作日志, 项目资产→category=项目名。
"""
text_ext = (
".md",
".txt",
".jsonl",
".py",
".c",
".h",
".cpp",
".hpp",
".json",
".csv",
".log",
".yaml",
".yml",
".ini",
".cfg",
".toml",
)
binary_ext = (
".pdf",
".docx",
".xlsx",
".pptx",
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".svg",
".bin",
".hex",
".elf",
".uf2",
".sch",
".brd",
".kicad_pcb",
".kicad_sch",
".zip",
".rar",
".7z",
".gz",
)
all_ext = text_ext + binary_ext
# 收集文件(支持单文件或目录;目录使用 os.walk 实现真正递归)
file_list = []
if os.path.isfile(dir_path):
# 单文件导入:校验扩展名后直接入列
fn = os.path.basename(dir_path)
if not fn.lower().endswith(all_ext):
log_line(f"[ERR] 不支持的文件类型: {fn}")
done()
return
file_list.append((project_name or "未归类", fn, dir_path))
elif recursive and import_type == "项目资产":
# 先获取顶级子目录名→项目名映射
top_dirs = {}
for entry in sorted(os.listdir(dir_path)):
sub = os.path.join(dir_path, entry)
if os.path.isdir(sub):
top_dirs[os.path.abspath(sub)] = project_name or entry
# 递归遍历所有文件,按所属顶级目录分配项目名
for root, dirs, files in os.walk(dir_path):
dirs.sort()
abs_root = os.path.abspath(root)
# 找到该文件所属的顶级项目
owner = None
for td, pn in top_dirs.items():
if abs_root == td or abs_root.startswith(td + os.sep):
owner = pn
break
for fn in sorted(files):
fp = os.path.join(root, fn)
if os.path.isfile(fp) and fn.lower().endswith(all_ext):
file_list.append((owner or project_name or "未归类", fn, fp))
else:
for root, dirs, files in os.walk(dir_path):
dirs.sort()
if not recursive:
dirs.clear() # 不递归:清空子目录列表
for fn in sorted(files):
fp = os.path.join(root, fn)
if os.path.isfile(fp) and fn.lower().endswith(all_ext):
pn = (
"工作日志"
if import_type == "工作日志"
else (project_name or os.path.basename(dir_path))
)
file_list.append((pn, fn, fp))
if not file_list:
log_line("[WARN] 未找到可导入文件")
done()
return
# 确保项目存在
conn = dam.get_conn()
proj_cache = {}
for pn, _, _ in file_list:
if pn in proj_cache:
continue
r = conn.execute("SELECT id FROM projects WHERE name=?", (pn,)).fetchone()
if r:
proj_cache[pn] = r["id"]
else:
t = dam.now()
conn.execute(
"INSERT INTO projects (name,status,description,created_at,updated_at) VALUES(?,'active','封存',?,?)",
(pn, t, t),
)
conn.commit()
proj_cache[pn] = conn.execute("SELECT last_insert_rowid()").fetchone()[
0
]
log_line(f"[OK] 创建项目: {pn}")
imported = skipped = 0
for idx, (pn, fn, fp) in enumerate(file_list, 1):
title = os.path.splitext(fn)[0]
ext = os.path.splitext(fn)[1].lower()
is_bin = fn.lower().endswith(binary_ext)
existing = conn.execute(
"SELECT id FROM knowledge_cards WHERE topic=? AND file_path=?",
(title, fp),
).fetchone()
if existing:
skipped += 1
log_line(f" [{idx}/{len(file_list)}] [SKIP] {fn}")
continue
t = dam.now()
mtime = datetime.fromtimestamp(os.path.getmtime(fp)).strftime("%Y-%m-%d")
if is_bin:
cat_map = {
".pdf": "PDF",
".docx": "Word",
".xlsx": "Excel",
".png": "图片",
".jpg": "图片",
".jpeg": "图片",
".gif": "图片",
".bmp": "图片",
".svg": "矢量图",
".bin": "固件",
".hex": "固件",
".elf": "固件",
".uf2": "固件",
".sch": "原理图",
".brd": "PCB",
".kicad_pcb": "KiCad",
".kicad_sch": "KiCad",
".zip": "压缩包",
".rar": "压缩包",
".7z": "压缩包",
".gz": "压缩包",
}
cn = cat_map.get(ext, "文档")
cat = "工作日志" if import_type == "工作日志" else pn
conn.execute(
"INSERT INTO knowledge_cards (topic,category,file_path,summary,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(title, cat, fp, f"[{cn}] {fp}", mtime, t, t),
)
else:
with open(fp, "r", encoding="utf-8", errors="replace") as fh:
raw = fh.read()
cat = "工作日志" if import_type == "工作日志" else pn
stored = raw[:10000]
conn.execute(
"INSERT INTO knowledge_cards (topic,category,file_path,summary,version,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(title, cat, fp, stored, mtime, t, t),
)
if len(raw) > 10000:
log_line(f" [WARN] {fn}: 内容{len(raw)}字, 仅存前10000字")
imported += 1
log_line(f" [{idx}/{len(file_list)}] [OK] {fn}")
conn.commit()
conn.close()
self.stats = {
"imported": imported,
"skipped": skipped,
"project": project_name or dir_path,
}
log_line(f"\n成功 {imported} 条, 跳过 {skipped} 条")
done()
def _import_corpus(self, dir_path, log_line, done):
"""在后台线程执行训练语料的 JSONL 导入。
每个 .jsonl 文件→一条 training_corpus 记录,record_count=有效行数。
自动统计 QA/decision/pitfall/other 四种类型数量,type 按最多类型设置。
Args:
dir_path: 包含 .jsonl 文件的目录
log_line: 日志回调
done: 完成回调
"""
"""导入训练语料:扫描 .jsonl,逐行解析。一个文件=一条记录,record_count=有效行数"""
# 支持单文件或目录导入
if os.path.isfile(dir_path):
if not dir_path.lower().endswith(".jsonl"):
log_line("[ERR] 训练语料仅支持 .jsonl 文件")
done()
return
base_dir = os.path.dirname(dir_path)
files = [os.path.basename(dir_path)]
else:
files = sorted(
[
f
for f in os.listdir(dir_path)
if f.lower().endswith(".jsonl")
and os.path.isfile(os.path.join(dir_path, f))
]
)
base_dir = dir_path
if not files:
log_line("[WARN] 未找到 .jsonl 文件")
done()
return
conn = dam.get_conn()
imported = skipped = 0
for fn in files:
fp = os.path.join(base_dir, fn)
existing = conn.execute("SELECT id FROM training_corpus WHERE file_path=?", (fp,)).fetchone()
if existing:
skipped += 1
log_line(f" [SKIP] {fn}")
continue
with open(fp, "r", encoding="utf-8", errors="replace") as fh:
raw = fh.read()
qa_count = dec_count = pf_count = other_count = 0
tags_set = set()
dates = []
for line in raw.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if "question" in obj and "answer" in obj:
qa_count += 1
elif "decision" in obj or "conclusion" in obj:
dec_count += 1
elif "problem" in obj or "solution" in obj:
pf_count += 1
else:
other_count += 1
_tags = obj.get("tags")
if _tags:
if isinstance(_tags, list):
tags_set.update(str(t) for t in _tags)
elif isinstance(_tags, str):
tags_set.update(
t.strip() for t in _tags.split(",") if t.strip()
)
else:
tags_set.add(str(_tags))
if obj.get("date"):
dates.append(obj["date"])
total_valid = qa_count + dec_count + pf_count + other_count
if total_valid == 0:
skipped += 1
log_line(f" [SKIP] {fn} (无有效行)")
continue
tags = ",".join(sorted(tags_set)[:5]) if tags_set else ""
date = dates[0] if dates else dam.now()[:10]
# 按多数类型设 type
if qa_count >= dec_count and qa_count >= pf_count and qa_count > 0:
best_type = "qa"
elif dec_count >= pf_count and dec_count > 0:
best_type = "decision"
elif pf_count > 0:
best_type = "pitfall"
else:
best_type = "qa"
t = dam.now()
conn.execute(
"INSERT INTO training_corpus (date,type,tags,file_path,record_count,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
(date, best_type, tags, fp, total_valid, t, t),
)
conn.commit()
imported += 1
parts = []
if qa_count:
parts.append(f"QA {qa_count}")
if dec_count:
parts.append(f"决策 {dec_count}")
if pf_count:
parts.append(f"踩坑 {pf_count}")
if other_count:
parts.append(f"其他 {other_count}")
log_line(f" [OK] {fn} -> {total_valid}条 ({', '.join(parts)})")
conn.close()
self.stats = {
"imported": imported,
"skipped": skipped,
"project": os.path.basename(dir_path),
}
log_line(f"\n导入 {imported} 个语料文件, 跳过 {skipped}")
done()
# ═══════════════════════ 2. 项目 ═══════════════════════
class ProjectPanel(ttk.Frame):
def __init__(self, parent, bg="#f5f6f8"):
super().__init__(parent)
self.bg = bg
self.build()
def build(self):
tk.Label(
self,
text="项目资产",
font=("Microsoft YaHei", 14, "bold"),
fg="#28518a",
bg=self.bg,
).pack(pady=(12, 0), anchor=tk.W)
top = ttk.Frame(self)
top.pack(fill=tk.X, pady=4)
ttk.Label(top, text="选择项目:").pack(side=tk.LEFT)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(
top, textvariable=self.proj_var, state="readonly", width=22
)
self.proj_cb.pack(side=tk.LEFT, padx=6)
self.proj_cb.bind("<<ComboboxSelected>>", lambda e: self._show())
ttk.Button(top, text="删除项目", command=self._del_proj).pack(
side=tk.LEFT, padx=6
)
self.info_lbl = ttk.Label(top, text="", foreground="gray")
self.info_lbl.pack(side=tk.RIGHT)
# 文件列表
cols = ("主题", "类型", "版本", "文件路径", "摘要", "导入日期")
self.tree = ttk.Treeview(self, columns=cols, show="headings", height=14)
w = {
"主题": 140,
"类型": 70,
"版本": 50,
"文件路径": 200,
"摘要": 240,
"导入日期": 100,
}
for c in cols:
self.tree.heading(c, text=c)
self.tree.column(c, width=w.get(c, 80), anchor=tk.W)
self.tree.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
ttk.Scrollbar(self, command=self.tree.yview).pack(fill=tk.Y, side=tk.RIGHT)
self.tree.bind("<Double-1>", self._view)
def refresh(self):
names = project_names()
self.proj_cb["values"] = names
if names:
self.proj_cb.current(0)
self._show()
def _show(self):
pn = self.proj_var.get()
if not pn:
return
conn = db_conn()
try:
# 项目下所有文件
rows = conn.execute(
"SELECT topic,version,file_path,summary,updated_at FROM knowledge_cards WHERE category=? ORDER BY updated_at DESC",
(pn,),
).fetchall()
cnt = len(rows)
self.info_lbl.configure(text=f"共 {cnt} 个文件")
self.tree.delete(*self.tree.get_children())
type_map = {
".md": "文档",
".txt": "文本",
".py": "Python",
".c": "C",
".h": "C头文件",
".cpp": "C++",
".json": "JSON",
".csv": "表格",
".log": "日志",
".yaml": "YAML",
".yml": "YAML",
".ini": "配置",
".cfg": "配置",
".toml": "配置",
".pdf": "PDF",
".docx": "Word",
".xlsx": "Excel",
".pptx": "PPT",
".png": "图片",
".jpg": "图片",
".jpeg": "图片",
".gif": "图片",
".bmp": "图片",
".svg": "矢量图",
".bin": "固件",
".hex": "HEX",
".elf": "ELF",
".uf2": "UF2",
".sch": "原理图",
".brd": "PCB",
".kicad_pcb": "KiCad",
".kicad_sch": "KiCad",
".zip": "压缩包",
".rar": "压缩包",
".7z": "压缩包",
".gz": "压缩包",
}
for r in rows:
ft = os.path.splitext(r["file_path"] or "")[1] if r["file_path"] else ""
ft = type_map.get(ft, ft)
self.tree.insert(
"",
tk.END,
values=(
r["topic"],
ft,
r["version"],
r["file_path"],
(r["summary"] or "")[:150],
r["updated_at"],
),
)
finally:
conn.close()
def _view(self, *_):
sel = self.tree.selection()
if not sel:
return
vals = self.tree.item(sel[0], "values")
conn = db_conn()
try:
r = conn.execute(
"SELECT * FROM knowledge_cards WHERE topic=? AND file_path=?",
(vals[0], vals[3]),
).fetchone()
if not r:
return
top = tk.Toplevel(self)
top.title(r["topic"])
top.geometry("750x480")
txt = tk.Text(top, wrap=tk.WORD, font=("Microsoft YaHei", 10))
txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
txt.insert(
tk.END,
f"名称: {r['topic']}\n分类: {r['category']}\n文件: {r['file_path']}\n日期: {r['version']}\n更新: {r['updated_at']}\n\n{'─' * 50}\n\n{r['summary']}",
)
finally:
conn.close()
def _del_proj(self):
pn = self.proj_var.get()
if not pn:
return
if not messagebox.askyesno(
"确认删除", f"删除项目「{pn}」及其中所有文件?此操作不可恢复。"
):
return
conn = db_conn()
try:
# BUG-06 修复:按 file_path(唯一) 清理,避免误删其他项目同名工作日志
files = conn.execute(
"SELECT id, file_path FROM knowledge_cards WHERE category=?", (pn,)
).fetchall()
for f in files:
conn.execute(
"DELETE FROM knowledge_cards WHERE category='工作日志' AND file_path=?",
(f["file_path"],),
)
conn.execute("DELETE FROM knowledge_cards WHERE category=?", (pn,))
conn.execute("DELETE FROM projects WHERE name=?", (pn,))
conn.commit()
except Exception as e:
messagebox.showerror("错误", str(e))
finally:
conn.close()
self.refresh()
# ═══════════════════════ 3. 工作日志 ═══════════════════════
class LogPanel(ttk.Frame):
def __init__(self, parent, bg="#f5f6f8"):
super().__init__(parent)
self.bg = bg
self.build()
def build(self):
tk.Label(
self,
text="工作日志",
font=("Microsoft YaHei", 14, "bold"),
fg="#28518a",
bg=self.bg,
).pack(pady=(12, 0), anchor=tk.W)
ttk.Label(self, text="按导入时间排列的日常工作记录", foreground="gray").pack()
cols = ("日期", "主题", "摘要", "文件路径", "更新时间")
self.tree = ttk.Treeview(self, columns=cols, show="headings", height=18)
w = {"日期": 90, "主题": 160, "摘要": 300, "文件路径": 200, "更新时间": 130}
for c in cols:
self.tree.heading(c, text=c)
self.tree.column(c, width=w.get(c, 80), anchor=tk.W)
self.tree.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
ttk.Scrollbar(self, command=self.tree.yview).pack(fill=tk.Y, side=tk.RIGHT)
self.tree.bind("<Double-1>", self._view)
self.tree.bind("<Delete>", lambda e: self._del())
bar = ttk.Frame(self)
bar.pack(fill=tk.X, pady=4)
ttk.Button(bar, text="删除选中", command=self._del).pack(side=tk.LEFT)
ttk.Button(bar, text="刷新", command=self.refresh).pack(side=tk.LEFT, padx=4)
def refresh(self):
conn = db_conn()
try:
rows = conn.execute(
"SELECT topic,version,summary,file_path,updated_at FROM knowledge_cards WHERE category='工作日志' ORDER BY updated_at DESC"
).fetchall()
finally:
conn.close()
self.tree.delete(*self.tree.get_children())
for r in rows:
self.tree.insert(
"",
tk.END,
values=(
r["version"],
r["topic"],
(r["summary"] or "")[:200],
r["file_path"],
r["updated_at"],
),
)
def _view(self, *_):
sel = self.tree.selection()
if not sel:
return
vals = self.tree.item(sel[0], "values")
conn = db_conn()
try:
r = conn.execute(
"SELECT * FROM knowledge_cards WHERE topic=? AND file_path=?",
(vals[1], vals[3]),
).fetchone()
if r:
top = tk.Toplevel(self)
top.title(r["topic"])
top.geometry("750x480")
txt = tk.Text(top, wrap=tk.WORD, font=("Microsoft YaHei", 10))
txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
txt.insert(
tk.END,
f"主题: {r['topic']}\n日期: {r['version']}\n文件: {r['file_path']}\n更新: {r['updated_at']}\n\n{'─' * 50}\n\n{r['summary']}",
)
finally:
conn.close()
def _del(self):
sel = self.tree.selection()
if not sel:
return
vals = self.tree.item(sel[0], "values")
if not messagebox.askyesno("确认", "删除选中日志?"):
return
conn = db_conn()
try:
conn.execute(
"DELETE FROM knowledge_cards WHERE topic=? AND file_path=?",
(vals[1], vals[3]),
)
conn.commit()
except Exception as e:
messagebox.showerror("错误", str(e))
finally:
conn.close()
self.refresh()
# ═══════════════════════ 4. 训练语料 ═══════════════════════
class CorpusPanel(ttk.Frame):
def __init__(self, parent, bg="#f5f6f8"):
super().__init__(parent)
self.bg = bg
self.build()
def build(self):
tk.Label(
self,
text="训练语料",
font=("Microsoft YaHei", 14, "bold"),
fg="#28518a",
bg=self.bg,
).pack(pady=(12, 0), anchor=tk.W)
ttk.Label(
self,
text=".jsonl 格式的 Q&A 数据,积累用于本地大模型训练",
foreground="gray",
).pack()
cols = ("日期", "标签", "文件路径", "记录数", "更新时间")
self.tree = ttk.Treeview(self, columns=cols, show="headings", height=18)
w = {"日期": 90, "标签": 120, "文件路径": 280, "记录数": 60, "更新时间": 130}
for c in cols:
self.tree.heading(c, text=c)
self.tree.column(c, width=w.get(c, 80), anchor=tk.W)
self.tree.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
ttk.Scrollbar(self, command=self.tree.yview).pack(fill=tk.Y, side=tk.RIGHT)
self.tree.bind("<Delete>", lambda e: self._del())
bar = ttk.Frame(self)
bar.pack(fill=tk.X, pady=4)
ttk.Button(bar, text="删除选中", command=self._del).pack(side=tk.LEFT)
ttk.Button(bar, text="刷新", command=self.refresh).pack(side=tk.LEFT, padx=4)
def refresh(self):
conn = db_conn()
try:
rows = conn.execute(
"SELECT * FROM training_corpus ORDER BY date DESC"
).fetchall()
finally:
conn.close()
self.tree.delete(*self.tree.get_children())
for r in rows:
self.tree.insert(
"",
tk.END,
values=(
r["date"],
r["tags"],
r["file_path"],
r["record_count"],
r["updated_at"],
),
)
def _del(self):
sel = self.tree.selection()
if not sel:
return
if not messagebox.askyesno("确认", "删除选中语料?"):
return
vals = self.tree.item(sel[0], "values")
conn = db_conn()
try:
conn.execute(
"DELETE FROM training_corpus WHERE file_path=? AND date=?",
(vals[2], vals[0]),
)
conn.commit()
except Exception as e:
messagebox.showerror("错误", str(e))
finally:
conn.close()
self.refresh()
# ═══════════════════════ 5. 搜索 ═══════════════════════
class SearchPanel(ttk.Frame):
"""全局搜索面板:输入即搜(实时)+AND逻辑(空格分隔)+三个结果标签页(项目/日志/语料)。"""
def __init__(self, parent, bg="#f5f6f8"):
super().__init__(parent)
self.bg = bg
self.build()
def build(self):
tk.Label(
self,
text="全局搜索",
font=("Microsoft YaHei", 14, "bold"),
fg="#28518a",
bg=self.bg,
).pack(pady=(12, 0), anchor=tk.W)
bar = ttk.Frame(self)
bar.pack(fill=tk.X, pady=6)
ttk.Label(bar, text="关键词:").pack(side=tk.LEFT)
self.kw = tk.StringVar()
self.kw.trace_add("write", lambda *_: self.refresh()) # 实时搜索
ttk.Entry(bar, textvariable=self.kw, width=36).pack(side=tk.LEFT, padx=6)
ttk.Button(bar, text="搜索", command=self.refresh).pack(side=tk.LEFT, padx=4)
ttk.Label(bar, text="空格分隔=AND逻辑", foreground="gray").pack(
side=tk.LEFT, padx=8
)
nb = ttk.Notebook(self)
nb.pack(fill=tk.BOTH, expand=True, pady=4)
self.trees = {}
for label, cols in [
("项目资产", ("主题", "分类", "日期", "摘要", "文件")),
("工作日志", ("主题", "日期", "摘要", "文件")),
("训练语料", ("日期", "标签", "文件路径", "记录数")),
]:
f = ttk.Frame(nb)
nb.add(f, text=label)
tree = ttk.Treeview(f, columns=cols, show="headings", height=14)
for c in cols:
tree.heading(c, text=c)
tree.column(c, width=110, anchor=tk.W)
tree.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
ttk.Scrollbar(f, command=tree.yview).pack(fill=tk.Y, side=tk.RIGHT)
self.trees[label] = tree
def _build_and_clause(self, keywords, fields):
"""构建 AND 逻辑的 SQL WHERE 子句。
多个关键词对每个字段做 LIKE 匹配,关键词间用 AND 连接。
例: ["ESP32","SPI"], ["topic","summary"]
→ (topic LIKE ? OR summary LIKE ?) AND (topic LIKE ? OR summary LIKE ?)
Returns: (clause_sql, params_list)
"""
clauses, params = [], []
for kw in keywords:
p = f"%{kw}%"
clauses.append("(" + " OR ".join([f"{f} LIKE ?" for f in fields]) + ")")
params.extend([p] * len(fields))
return " AND ".join(clauses), params
def refresh(self):
"""执行搜索:关键词AND逻辑,分三个标签页显示结果。"""
kw = self.kw.get().strip()
if not kw:
for t in self.trees.values():
t.delete(*t.get_children())
return
keywords = kw.split()
conn = db_conn()
try:
# 项目资产 — AND 匹配 topic/summary/file_path
kc_clause, kc_params = self._build_and_clause(
keywords, ["topic", "summary", "file_path"]
)
kc = (
conn.execute(
f"SELECT topic,category,version,summary,file_path FROM knowledge_cards WHERE category!='工作日志' AND {kc_clause} ORDER BY updated_at DESC LIMIT 60",
kc_params,
).fetchall()
if kc_clause
else []
)
# 工作日志 — AND 匹配 topic/summary
lg_clause, lg_params = self._build_and_clause(
keywords, ["topic", "summary"]
)
lg = (
conn.execute(
f"SELECT topic,version,summary,file_path FROM knowledge_cards WHERE category='工作日志' AND {lg_clause} ORDER BY updated_at DESC LIMIT 60",
lg_params,
).fetchall()
if lg_clause
else []
)
# 语料 — AND 匹配 tags/file_path
cp_clause, cp_params = self._build_and_clause(
keywords, ["tags", "file_path"]
)
cp = (
conn.execute(
f"SELECT date,tags,file_path,record_count FROM training_corpus WHERE {cp_clause} ORDER BY date DESC LIMIT 60",
cp_params,
).fetchall()
if cp_clause
else []
)
finally:
conn.close()
for label, rows, cols in [
("项目资产", kc, ["topic", "category", "version", "summary", "file_path"]),
("工作日志", lg, ["topic", "version", "summary", "file_path"]),
("训练语料", cp, ["date", "tags", "file_path", "record_count"]),
]:
tree = self.trees[label]
tree.delete(*tree.get_children())
for r in rows:
tree.insert("", tk.END, values=tuple(r.get(c, "") for c in cols))
# ═══════════════════════ 6. 导出 ═══════════════════════
class ExportPanel(ttk.Frame):
def __init__(self, parent, bg="#f5f6f8"):
super().__init__(parent)
self.bg = bg
self.build()
def build(self):
tk.Label(
self,
text="导出报告",
font=("Microsoft YaHei", 14, "bold"),
fg="#28518a",
bg=self.bg,
).pack(pady=(12, 0), anchor=tk.W)
bar = ttk.Frame(self)
bar.pack(fill=tk.X, pady=6)
ttk.Label(bar, text="选择项目:").pack(side=tk.LEFT)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(
bar, textvariable=self.proj_var, state="readonly", width=22
)
self.proj_cb.pack(side=tk.LEFT, padx=6)
ttk.Button(bar, text="导出 MD", command=self._export_md).pack(
side=tk.LEFT, padx=4
)
ttk.Button(bar, text="导出 JSONL", command=self._export_jsonl).pack(
side=tk.LEFT, padx=4
)
ttk.Button(bar, text="导出全部 JSONL", command=self._export_all_jsonl).pack(
side=tk.LEFT, padx=4
)
self.pv = tk.Text(self, height=22, font=("Consolas", 9), wrap=tk.WORD)
self.pv.pack(fill=tk.BOTH, expand=True, pady=4)
def refresh(self):
names = project_names()
self.proj_cb["values"] = names
if names:
self.proj_cb.current(0)
def _gen_md(self, pn):
conn = db_conn()
try:
pr = conn.execute("SELECT * FROM projects WHERE name=?", (pn,)).fetchone()
if not pr:
return f"# 未找到: {pn}"
p = dict(pr)
rows = conn.execute(
"SELECT * FROM knowledge_cards WHERE category=? ORDER BY updated_at DESC",
(pn,),
).fetchall()
corpus = conn.execute(
"SELECT * FROM training_corpus ORDER BY date DESC"
).fetchall()
logs = conn.execute(
"SELECT * FROM knowledge_cards WHERE category='工作日志' ORDER BY updated_at DESC"
).fetchall()
md = [
f"# {p['name']}\n\n- 状态: {p['status']} | 描述: {p['description']}\n- 创建: {p['created_at']} | 更新: {p['updated_at']}\n"
]
md.append(f"\n---\n## 项目文件 ({len(rows)}个)\n")
for r in rows:
md.append(
f"### {r['topic']}\n- 类型: {os.path.splitext(r['file_path'] or '')[1]} | 文件: {r['file_path']} | 日期: {r['version']}\n\n{r['summary'][:3000]}\n\n---\n"
)
md.append(f"\n## 工作日志 ({len(logs)}条)\n")
for r in logs:
md.append(
f"### {r['topic']}\n- 日期: {r['version']}\n\n{r['summary'][:2000]}\n\n---\n"
)
md.append(f"\n## 训练语料 ({len(corpus)}条)\n")
for r in corpus:
md.append(
f"- {r['date']} | {r['type']} | {r['tags']} | {r['file_path']} ({r['record_count']}条)\n"
)
md.append(f"\n> 导出: {dam.now()}")
return "\n".join(md)
finally:
conn.close()
def _gen_jsonl(self, pn):
"""项目文件导出为 JSONL 格式,可直接喂给大模型"""
lines = []
conn = db_conn()
try:
rows = conn.execute(
"SELECT * FROM knowledge_cards WHERE category=? ORDER BY updated_at DESC",
(pn,),
).fetchall()
for r in rows:
obj = {
"source": "project",
"project": pn,
"file": r["topic"],
"type": os.path.splitext(r["file_path"] or "")[1],
"date": r["version"],
"content": (r["summary"] or ""),
}
lines.append(json.dumps(obj, ensure_ascii=False))
finally:
conn.close()
return "\n".join(lines)
def _gen_all_jsonl(self):
"""全部资产导出为 JSONL"""
lines = []
conn = db_conn()
try:
# 项目文件
kc = conn.execute(
"SELECT * FROM knowledge_cards WHERE category!='工作日志' ORDER BY updated_at DESC"
).fetchall()
for r in kc:
obj = {
"source": "project",
"project": r["category"],
"file": r["topic"],
"type": os.path.splitext(r["file_path"] or "")[1],
"date": r["version"],
"content": (r["summary"] or ""),
}
lines.append(json.dumps(obj, ensure_ascii=False))
# 工作日志
lg = conn.execute(
"SELECT * FROM knowledge_cards WHERE category='工作日志' ORDER BY updated_at DESC"
).fetchall()
for r in lg:
obj = {
"source": "worklog",
"topic": r["topic"],
"date": r["version"],
"content": (r["summary"] or ""),
}
lines.append(json.dumps(obj, ensure_ascii=False))
# 语料
cp = conn.execute(
"SELECT * FROM training_corpus ORDER BY date DESC"
).fetchall()
for r in cp:
obj = {
"source": "corpus",
"date": r["date"],
"type": r["type"],
"tags": r["tags"],
"file": r["file_path"],
}
lines.append(json.dumps(obj, ensure_ascii=False))
finally:
conn.close()
return "\n".join(lines)
def _export_md(self):
pn = self.proj_var.get()
if not pn:
messagebox.showwarning("提示", "请选择项目")
return
md = self._gen_md(pn)
self.pv.delete("1.0", tk.END)
self.pv.insert("1.0", md)
path = filedialog.asksaveasfilename(
defaultextension=".md",
filetypes=[("Markdown", "*.md")],
initialfile=f"{pn.replace(' ', '_')}.md",
)
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(md)
messagebox.showinfo("完成", f"已保存: {path}")
def _export_jsonl(self):
pn = self.proj_var.get()
if not pn:
messagebox.showwarning("提示", "请选择项目")
return
jl = self._gen_jsonl(pn)
self.pv.delete("1.0", tk.END)
self.pv.insert("1.0", jl)
path = filedialog.asksaveasfilename(
defaultextension=".jsonl",
filetypes=[("JSONL", "*.jsonl")],
initialfile=f"{pn.replace(' ', '_')}.jsonl",
)
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(jl)
messagebox.showinfo("完成", f"已保存: {path}")
def _export_all_jsonl(self):
pn = self.proj_var.get() or "全部资产"
jl = self._gen_all_jsonl()
lines = jl.splitlines()
self.pv.delete("1.0", tk.END)
self.pv.insert("1.0", "\n".join(lines[:50]) + f"\n\n... (共 {len(lines)} 行)")
path = filedialog.asksaveasfilename(
defaultextension=".jsonl",
filetypes=[("JSONL", "*.jsonl")],
initialfile=f"{pn}_全部.jsonl",
)
if path:
with open(path, "w", encoding="utf-8") as f:
f.write(jl)
messagebox.showinfo("完成", f"已保存: {path}")
# ═══════════════════════ 启动 ═══════════════════════
if __name__ == "__main__":
app = App()
app.mainloop()