#!/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()
