#!/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=True,EXE路径=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( "", lambda e, b=btn: b.configure( bg="#344863" if not getattr(b, "_active", False) else "#3b6fb6" ), ) btn.bind( "", 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("<>", 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("", 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("", self._view) self.tree.bind("", 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("", 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()