1556 lines
57 KiB
Python
1556 lines
57 KiB
Python
#!/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(
|
||
"<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()
|