import os import json import sqlite3 import webbrowser from datetime import datetime, date, timezone import tkinter as tk import time from tkinter import messagebox, simpledialog import customtkinter as ctk from tksheet import Sheet from tkcalendar import Calendar # ----------------------- CONFIG ----------------------- # region 配置 VERSION = "v1.0.0" DB_PATH = "tasks.db" # 数据库文件路径 TEMPLATES_PATH = "templates.json" # 检索模板文件路径 # UI theme CTK_THEME = "dark" CTK_COLOR_THEME = "blue" # ----------------------- ReadMe ----------------------- # 想要修改类型、状态、优先级等选项,请修改以下常量部分。 # 需要修改对应的OPTIONS和DISPLAY,以及COMPOSITE_WEIGHTS、SORT_ORDER # 也就是新增一个选项,需要在4个地方修改 # ------------------------------------------------------ # Options PRIORITY_OPTIONS = ["SSS", "SS", "S", "A", "B", "C", "D", "E"] TYPE_OPTIONS = ["Bug", "需求", "其他"] STATUS_OPTIONS = ["待处理", "进行中", "已完成", "保持跟进", "搁置", "取消"] # Display sets (icon + background) PRIORITY_DISPLAY = { "SSS": {"icon": "🔴🔴🔴", "bg": "#ff3535"}, "SS": {"icon": "🔴🔴", "bg": "#fe5151"}, "S": {"icon": "🔴", "bg": "#ff9898"}, "A": {"icon": "🟠", "bg": "#ffd79c"}, "B": {"icon": "🟡", "bg": "#ffffb3"}, "C": {"icon": "🟢", "bg": "#d6ffb3"}, "D": {"icon": "🟣", "bg": "#e6b3ff"}, "E": {"icon": "⚪", "bg": "#f0f0f0"}, } TYPE_DISPLAY = { "Bug": {"icon": "🐞", "bg": "#ed7e7e"}, "需求": {"icon": "✨", "bg": "#a0e9c4"}, "其他": {"icon": "📝", "bg": "#c1d9fe"}, } STATUS_DISPLAY = { "待处理": {"icon": "⏳", "bg": "#f7e086"}, "进行中": {"icon": "🔧", "bg": "#6486f5"}, "已完成": {"icon": "✅", "bg": "#349f34"}, "搁置": {"icon": "⏸️", "bg": "#85827f"}, "保持跟进": {"icon": "🔁", "bg": "#c9ffcc"}, "取消": {"icon": "❎", "bg": "#77637b"}, } ROWS_COLORS = [ "#ffffff", "#F7F7F7", ] # PROCESSED / Today display (icon + background) - shows whether task has logs for today PROCESSED_DISPLAY = { "yes": {"icon": "✅", "bg": "#d6f5d6"}, "no": {"icon": "", "bg": "#ffffff"} } # SORT ORDER (Rule A) - used for table-column sorting (integer ranks) SORT_ORDER = { "priority": {"SSS": 8, "SS": 7, "S": 6, "A": 5, "B": 4, "C": 3, "D": 2, "E": 1}, "type": {"Bug": 3, "需求": 2, "其他": 1}, "status": {"待处理": 6, "进行中": 5, "保持跟进": 4, "搁置": 3, "取消": 2, "已完成": 1} } # COMPOSITE WEIGHTS (Rule B) - used in composite score calculation COMPOSITE_WEIGHTS = { "priority": {"SSS": 4.0, "SS": 3.0, "S": 2.0, "A": 1.0, "B": 0.8, "C": 0.6, "D": 0.4, "E": 0.2}, "type": {"Bug": 1.5, "需求": 1.0, "其他": 1.0}, "status": {"待处理": 0.9, "进行中": 1.0, "保持跟进": 0.1, "搁置": 0.1, "取消": 0.0, "已完成": 0.0}, # age factor multiplier (per day) "age_factor": 0.1 } # Columns (for sheet) 这里的顺序即列显示顺序 COLUMNS = [ ("processed_today", "当日处理"), ("sid", "ID"), ("type", "类型"), ("status", "状态"), ("priority", "优先级"), ("composite", "综合优先级"), ("title", "标题"), ("brief", "简介"), ("notes", "备注"), ("start_date", "开始日期"), ("last_processed", "最后处理日期"), ("links_count", "链接"), ] # Derived column indices (use these instead of hardcoded numbers so # the visual column order can be changed centrally by editing COLUMNS) COL_INDEX = {k: i for i, (k, _) in enumerate(COLUMNS)} COL_SID = COL_INDEX.get("sid") COL_TITLE = COL_INDEX.get("title") COL_BRIEF = COL_INDEX.get("brief") COL_PRIORITY = COL_INDEX.get("priority") COL_TYPE = COL_INDEX.get("type") COL_STATUS = COL_INDEX.get("status") COL_PROCESSED_TODAY = COL_INDEX.get("processed_today") COL_LAST_PROCESSED = COL_INDEX.get("last_processed") COL_START_DATE = COL_INDEX.get("start_date") COL_LINKS_COUNT = COL_INDEX.get("links_count") COL_NOTES = COL_INDEX.get("notes") COL_COMPOSITE = COL_INDEX.get("composite") # endregion # ----------------------- DB LAYER ----------------------- class TaskDB: def __init__(self, path=DB_PATH): need_init = not os.path.exists(path) self.conn = sqlite3.connect(path, check_same_thread=False) self.conn.row_factory = sqlite3.Row if need_init: self._ensure_schema() else: self._migrate_schema() def _ensure_schema(self): cur = self.conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS tasks ( sid INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, brief TEXT, description TEXT, priority TEXT, type TEXT, status TEXT, start_date TEXT, links TEXT, notes TEXT, updated_at TEXT ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS task_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, date TEXT ) """) self.conn.commit() def _migrate_schema(self): # Safe migration: add missing columns and ensure task_logs exist without dropping data cur = self.conn.cursor() # if tasks table missing, create all cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'") if not cur.fetchone(): self._ensure_schema(); return # ensure columns exist, add if missing (safe) cur.execute("PRAGMA table_info(tasks)") cols = [r["name"] for r in cur.fetchall()] adds = [] if "brief" not in cols: adds.append(("brief", "TEXT")) if "description" not in cols: adds.append(("description", "TEXT")) if "priority" not in cols: adds.append(("priority", "TEXT")) if "type" not in cols: adds.append(("type", "TEXT")) if "status" not in cols: adds.append(("status", "TEXT")) if "start_date" not in cols: adds.append(("start_date", "TEXT")) if "links" not in cols: adds.append(("links", "TEXT")) if "notes" not in cols: adds.append(("notes", "TEXT")) if "updated_at" not in cols: adds.append(("updated_at", "TEXT")) if "sid" not in cols: adds.append(("sid", "INTEGER")) for cname, ctype in adds: try: cur.execute(f"ALTER TABLE tasks ADD COLUMN {cname} {ctype}") except Exception: pass # ensure logs table exists cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_logs'") if not cur.fetchone(): cur.execute(''' CREATE TABLE IF NOT EXISTS task_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, date TEXT ) ''') self.conn.commit() # populate sid for existing rows if missing try: cur.execute("SELECT MAX(sid) as maxsid FROM tasks") rmax = cur.fetchone(); maxsid = rmax[0] if rmax else None if maxsid is None: maxsid = 0 cur.execute("SELECT rowid, sid FROM tasks WHERE sid IS NULL ORDER BY start_date, rowid") rows = [r for r in cur.fetchall()] for row in rows: maxsid += 1 try: cur.execute("UPDATE tasks SET sid=? WHERE rowid=?", (maxsid, row[0])) except Exception: pass self.conn.commit() except Exception: pass return def add_task(self, task: dict): now = datetime.now(timezone.utc).isoformat() cur = self.conn.cursor() cur.execute(""" INSERT INTO tasks (title, brief, description, priority, type, status, start_date, links, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task.get("title", ""), task.get("brief", ""), task.get("description", ""), task.get("priority"), task.get("type"), task.get("status"), task.get("start_date"), json.dumps(task.get("links", []), ensure_ascii=False), task.get("notes", ""), now )) self.conn.commit() return cur.lastrowid def update_task(self, tid: int, task: dict): now = datetime.now(timezone.utc).isoformat() self.conn.execute(""" UPDATE tasks SET title=?, brief=?, description=?, priority=?, type=?, status=?, start_date=?, links=?, notes=?, updated_at=? WHERE sid=? """, ( task.get("title", ""), task.get("brief", ""), task.get("description", ""), task.get("priority"), task.get("type"), task.get("status"), task.get("start_date"), json.dumps(task.get("links", []), ensure_ascii=False), task.get("notes", ""), now, tid )) self.conn.commit() def delete_task(self, tid: int): self.conn.execute("DELETE FROM tasks WHERE sid=?", (tid,)) self.conn.execute("DELETE FROM task_logs WHERE task_id=?", (tid,)) self.conn.commit() def get_task(self, tid: int): cur = self.conn.execute("SELECT * FROM tasks WHERE sid=?", (tid,)) row = cur.fetchone() return dict(row) if row else None def list_tasks(self, text_search=None, types=None, statuses=None, priorities=None): # include last_processed as the most recent date in task_logs for each task sql = "SELECT t.*, (SELECT MAX(date) FROM task_logs l WHERE l.task_id = t.sid) as last_processed FROM tasks t" where = [] params = [] if text_search: term = f"%{text_search}%" where.append("(title LIKE ? OR brief LIKE ? OR description LIKE ? OR notes LIKE ?)") params.extend([term, term, term, term]) if types: placeholders = ",".join(["?"] * len(types)) where.append(f"type IN ({placeholders})"); params.extend(types) if statuses: placeholders = ",".join(["?"] * len(statuses)) where.append(f"status IN ({placeholders})"); params.extend(statuses) if priorities: placeholders = ",".join(["?"] * len(priorities)) where.append(f"priority IN ({placeholders})"); params.extend(priorities) if where: sql += " WHERE " + " AND ".join(where) cur = self.conn.execute(sql, params) return [dict(r) for r in cur.fetchall()] # logs def add_log(self, task_id: int, date_str: str): # prevent duplicate log for same date cur = self.conn.execute("SELECT 1 FROM task_logs WHERE task_id=? AND date=?", (task_id, date_str)) if cur.fetchone(): return False self.conn.execute("INSERT INTO task_logs (task_id, date) VALUES (?, ?)", (task_id, date_str)) self.conn.commit() return True def remove_log(self, task_id: int, date_str: str): self.conn.execute("DELETE FROM task_logs WHERE task_id=? AND date=?", (task_id, date_str)) self.conn.commit() def get_logs_for_task(self, task_id: int): cur = self.conn.execute("SELECT date FROM task_logs WHERE task_id=? ORDER BY date", (task_id,)) return [r["date"] for r in cur.fetchall()] def get_tasks_on_date(self, date_str: str): cur = self.conn.execute(""" SELECT t.* FROM tasks t JOIN task_logs l ON l.task_id = t.sid WHERE l.date=? """, (date_str,)) return [dict(r) for r in cur.fetchall()] # ----------------------- TEMPLATES ----------------------- class TemplateStore: def __init__(self, path=TEMPLATES_PATH): self.path = path self.templates = {} self._load() def _load(self): if os.path.exists(self.path): try: with open(self.path, "r", encoding="utf8") as f: self.templates = json.load(f) except Exception: self.templates = {} else: self.templates = {} def save(self): with open(self.path, "w", encoding="utf8") as f: json.dump(self.templates, f, indent=2, ensure_ascii=False) def add(self, name: str, cfg: dict): self.templates[name] = cfg self.save() def delete(self, name: str): if name in self.templates: del self.templates[name]; self.save() def list_names(self): return list(self.templates.keys()) def get(self, name: str): return self.templates.get(name) # ----------------------- COMPOSITE SCORE (Rule B) ----------------------- def compute_composite_score(task_row: dict) -> float: """ Compute composite priority score using COMPOSITE_WEIGHTS. Higher number => more urgent. """ # 读取数据 start_date = task_row.get("start_date") days = 0 try: if start_date: dt = datetime.fromisoformat(start_date) # convert to local naive days days = (datetime.now(dt.tzinfo or timezone.utc).date() - dt.date()).days if days < 0: days = 0 except Exception: days = 0 d_score = COMPOSITE_WEIGHTS.get("age_factor", 0.1) * days + 1 p_score = COMPOSITE_WEIGHTS["priority"].get(task_row.get("priority"), 1.0) t_score = COMPOSITE_WEIGHTS["type"].get(task_row.get("type"), 1.0) s_score = COMPOSITE_WEIGHTS["status"].get(task_row.get("status"), 1.0) # 计算综合优先级分数 score = p_score * t_score * s_score * d_score return round(float(score), 6) # ----------------------- UI: Editor (CTk) ----------------------- class TaskEditor(ctk.CTkToplevel): def __init__(self, parent, db: TaskDB, task_id: int = None, on_save=None): super().__init__(parent) self.parent = parent self.db = db self.task_id = task_id self.on_save = on_save self.title("编辑任务" if task_id else "新增任务") self.geometry("640x900") self.transient(parent) self.grab_set() # fields self.title_var = ctk.StringVar() self.brief_var = ctk.StringVar() self.start_var = ctk.StringVar(value=date.today().isoformat()) self.priority_var = ctk.StringVar() self.type_var = ctk.StringVar() self.status_var = ctk.StringVar() self.links_text = None self.notes_text = None self._logs = [] self._build_ui() if self.task_id: self.load_task() def _build_ui(self): pad = 8 frm = ctk.CTkFrame(self, corner_radius=8) frm.pack(fill="both", expand=True, padx=12, pady=12) # Title ctk.CTkLabel(frm, text="Title *").grid(row=0, column=0, sticky="w", padx=pad) ctk.CTkEntry(frm, textvariable=self.title_var).grid(row=1, column=0, columnspan=3, sticky="ew", padx=pad) # Brief ctk.CTkLabel(frm, text="Brief").grid(row=2, column=0, sticky="w", padx=pad, pady=(10,0)) ctk.CTkEntry(frm, textvariable=self.brief_var).grid(row=3, column=0, columnspan=3, sticky="ew", padx=pad) # Description (notes) ctk.CTkLabel(frm, text="Notes / Description").grid(row=4, column=0, sticky="w", padx=pad, pady=(10,0)) self.notes_text = ctk.CTkTextbox(frm, height=120) self.notes_text.grid(row=5, column=0, columnspan=3, sticky="nsew", padx=pad) # priority / type / status ctk.CTkLabel(frm, text="Priority").grid(row=6, column=0, sticky="w", padx=pad, pady=(10,0)) ctk.CTkLabel(frm, text="Type").grid(row=6, column=1, sticky="w", padx=pad, pady=(10,0)) ctk.CTkLabel(frm, text="Status").grid(row=6, column=2, sticky="w", padx=pad, pady=(10,0)) self.priority_cb = ctk.CTkComboBox(frm, values=PRIORITY_OPTIONS) self.priority_cb.grid(row=7, column=0, sticky="ew", padx=pad) self.type_cb = ctk.CTkComboBox(frm, values=TYPE_OPTIONS) self.type_cb.grid(row=7, column=1, sticky="ew", padx=pad) self.status_cb = ctk.CTkComboBox(frm, values=STATUS_OPTIONS) self.status_cb.grid(row=7, column=2, sticky="ew", padx=pad) # start date ctk.CTkLabel(frm, text="Start Date (YYYY-MM-DD)").grid(row=8, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0)) ctk.CTkEntry(frm, textvariable=self.start_var).grid(row=9, column=0, columnspan=3, sticky="ew", padx=pad) # links ctk.CTkLabel(frm, text="Links (one per line)").grid(row=10, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0)) self.links_text = ctk.CTkTextbox(frm, height=80) self.links_text.grid(row=11, column=0, columnspan=3, sticky="nsew", padx=pad) # logs (processed dates) ctk.CTkLabel(frm, text="Processed Dates (handling records)").grid(row=12, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0)) logs_frame = ctk.CTkFrame(frm) logs_frame.grid(row=13, column=0, columnspan=3, sticky="ew", padx=pad, pady=(4,0)) ctk.CTkButton(logs_frame, text="Mark Today", width=120, command=self._mark_today).pack(side="left", padx=6, pady=6) ctk.CTkButton(logs_frame, text="Add Date...", width=120, command=self._add_date_dialog).pack(side="left", padx=6, pady=6) ctk.CTkButton(logs_frame, text="Remove Selected", fg_color="#cc4444", hover_color="#aa3333", command=self._remove_selected_date).pack(side="right", padx=6, pady=6) # list widget for logs (tk Listbox embedded) self.logs_listbox = tk.Listbox(frm, height=5) self.logs_listbox.grid(row=14, column=0, columnspan=3, sticky="nsew", padx=pad, pady=(4,0)) # buttons btn_row = ctk.CTkFrame(frm) btn_row.grid(row=15, column=0, columnspan=3, pady=(12,6)) ctk.CTkButton(btn_row, text="Save", command=self._save).pack(side="left", padx=8) ctk.CTkButton(btn_row, text="Cancel", fg_color="#888", hover_color="#666", command=self.destroy).pack(side="left", padx=8) # grid weights frm.grid_rowconfigure(5, weight=1) frm.grid_rowconfigure(11, weight=0) frm.grid_rowconfigure(14, weight=0) frm.grid_columnconfigure(0, weight=1) frm.grid_columnconfigure(1, weight=1) frm.grid_columnconfigure(2, weight=1) def load_task(self): row = self.db.get_task(self.task_id) if not row: messagebox.showerror("Error", "Task not found") self.destroy(); return self.title_var.set(row.get("title") or "") self.brief_var.set(row.get("brief") or "") self.notes_text.delete("1.0", "end"); self.notes_text.insert("1.0", row.get("notes") or "") self.priority_cb.set(row.get("priority") or "") self.type_cb.set(row.get("type") or "") self.status_cb.set(row.get("status") or "") self.start_var.set(row.get("start_date") or "") links = json.loads(row.get("links") or "[]") self.links_text.delete("1.0", "end"); self.links_text.insert("1.0", "\n".join(links)) # logs self._logs = self.db.get_logs_for_task(self.task_id) self._refresh_logs() def _refresh_logs(self): self.logs_listbox.delete(0, "end") for d in sorted(self._logs): self.logs_listbox.insert("end", d) def _mark_today(self): today = date.today().isoformat() if not self.task_id: # not saved yet: append to local list if today not in self._logs: self._logs.append(today); self._refresh_logs() return ok = self.db.add_log(self.task_id, today) if ok: self._logs = self.db.get_logs_for_task(self.task_id); self._refresh_logs() messagebox.showinfo("OK", f"Marked {today}") else: messagebox.showinfo("Info", "Already marked today") def _add_date_dialog(self): # create small calendar window win = tk.Toplevel(self) win.title("Pick date") cal = Calendar(win, selectmode="day") cal.pack(padx=8, pady=8) def ok(): ds = cal.selection_get().isoformat() if not self.task_id: if ds not in self._logs: self._logs.append(ds); self._refresh_logs() else: ok = self.db.add_log(self.task_id, ds) if ok: self._logs = self.db.get_logs_for_task(self.task_id); self._refresh_logs() messagebox.showinfo("OK", f"Added {ds}") else: messagebox.showinfo("Info", "Already exists") win.destroy() tk.Button(win, text="OK", command=ok).pack(pady=6) def _remove_selected_date(self): sel = self.logs_listbox.curselection() if not sel: return val = self.logs_listbox.get(sel[0]) if not self.task_id: # local remove self._logs = [d for d in self._logs if d != val]; self._refresh_logs(); return self.db.remove_log(self.task_id, val) self._logs = self.db.get_logs_for_task(self.task_id); self._refresh_logs() def _gather(self): links = [ln.strip() for ln in self.links_text.get("1.0", "end").splitlines() if ln.strip()] return { "title": self.title_var.get().strip(), "brief": self.brief_var.get().strip(), "notes": self.notes_text.get("1.0", "end").strip(), "priority": self.priority_cb.get() or None, "type": self.type_cb.get() or None, "status": self.status_cb.get() or None, "start_date": self.start_var.get().strip() or date.today().isoformat(), "links": links, "processed_dates": getattr(self, "_logs", []) } def _save(self): data = self._gather() if not data["title"]: messagebox.showwarning("Validation", "Title required") return if self.task_id: # update self.db.update_task(self.task_id, { "title": data["title"], "brief": data["brief"], "description": "", # we didn't include separate description here "priority": data["priority"], "type": data["type"], "status": data["status"], "start_date": data["start_date"], "links": data["links"], "notes": data["notes"] }) # update logs: remove all existing and re-add from _logs (simple approach) existing = self.db.get_logs_for_task(self.task_id) for d in existing: self.db.remove_log(self.task_id, d) for d in data["processed_dates"]: self.db.add_log(self.task_id, d) else: # create then add logs tid = self.db.add_task({ # "id": str(uuid.uuid4()), "title": data["title"], "brief": data["brief"], "description": "", "priority": data["priority"], "type": data["type"], "status": data["status"], "start_date": data["start_date"], "links": data["links"], "notes": data["notes"], }) for d in data["processed_dates"]: self.db.add_log(tid, d) if callable(self.on_save): self.on_save() self.destroy() # ----------------------- MAIN APP (CTk) ----------------------- class TaskManagerApp(ctk.CTk): def __init__(self): super().__init__() ctk.set_appearance_mode(CTK_THEME) ctk.set_default_color_theme(CTK_COLOR_THEME) self.title(f"Task Manager ( {VERSION} )") self.geometry("1700x820") # DB + templates self.db = TaskDB() self.templates = TemplateStore() # state self.order_by = "composite" # default sort by composite self.order_asc = False # debug: set via env TASKMGR_DEBUG=1 to print click mapping info to console # self._debug_tksheet = True if os.environ.get("TASKMGR_DEBUG","0") == "1" else False self._debug_tksheet = True # guard to prevent duplicate toggles from multiple bound handlers self._last_header_toggle = None # build UI self._build_ui() self.refresh_table() def _build_ui(self): # top controls top = ctk.CTkFrame(self) top.pack(fill="x", padx=10, pady=8) ctk.CTkLabel(top, text="Search:").pack(side="left", padx=(6,4)) self.search_var = ctk.StringVar() ctk.CTkEntry(top, textvariable=self.search_var, width=320).pack(side="left") ctk.CTkButton(top, text="Apply", command=self.refresh_table).pack(side="left", padx=6) ctk.CTkButton(top, text="Clear", fg_color="#888", hover_color="#666", command=self._clear_filters).pack(side="left", padx=6) ctk.CTkButton(top, text="Clear Sort", fg_color="#888", hover_color="#666", command=self._clear_sort).pack(side="left", padx=6) # debug label (visible when debug mode enabled) self._debug_label_var = ctk.StringVar(value="") self._debug_label = ctk.CTkLabel(top, textvariable=self._debug_label_var, text_color="#aaa") if self._debug_tksheet: self._debug_label.pack(side="right", padx=6) self._debug_label_var.set("Debug active") # filter multi-selects (tk Listbox wrapped) filter_frame = ctk.CTkFrame(self) filter_frame.pack(fill="x", padx=10, pady=(0,8)) # type tf = ctk.CTkFrame(filter_frame); tf.pack(side="left", padx=6) ctk.CTkLabel(tf, text="类型").pack(anchor="w") self.type_listbox = tk.Listbox(tf, selectmode="multiple", exportselection=False, height=4) for it in TYPE_OPTIONS: self.type_listbox.insert("end", it) self.type_listbox.pack() # status sf = ctk.CTkFrame(filter_frame); sf.pack(side="left", padx=6) ctk.CTkLabel(sf, text="状态").pack(anchor="w") self.status_listbox = tk.Listbox(sf, selectmode="multiple", exportselection=False, height=4) for it in STATUS_OPTIONS: self.status_listbox.insert("end", it) self.status_listbox.pack() # priority pf = ctk.CTkFrame(filter_frame); pf.pack(side="left", padx=6) ctk.CTkLabel(pf, text="优先级").pack(anchor="w") self.priority_listbox = tk.Listbox(pf, selectmode="multiple", exportselection=False, height=4) for it in PRIORITY_OPTIONS: self.priority_listbox.insert("end", it) self.priority_listbox.pack() # template controls tpl_frame = ctk.CTkFrame(filter_frame); tpl_frame.pack(side="right", padx=6) ctk.CTkLabel(tpl_frame, text="筛选模板").pack(anchor="w") self.template_combo = ctk.CTkComboBox(tpl_frame, values=["(none)"] + self.templates.list_names(), width=220) self.template_combo.set("(none)"); self.template_combo.pack(side="left") ctk.CTkButton(tpl_frame, text="保存", command=self.save_template).pack(side="left", padx=6) ctk.CTkButton(tpl_frame, text="加载", command=self.load_template).pack(side="left", padx=6) ctk.CTkButton(tpl_frame, text="删除", fg_color="#aa5555", hover_color="#aa3333", command=self.delete_template).pack(side="left", padx=6) # action buttons btns = ctk.CTkFrame(self); btns.pack(fill="x", padx=10, pady=(0,8)) ctk.CTkButton(btns, text="新增任务", command=self.add_task).pack(side="left", padx=8) ctk.CTkButton(btns, text="编辑选中任务", command=self.edit_selected).pack(side="left", padx=8) ctk.CTkButton(btns, text="删除筛选中任务", fg_color="#cc4444", hover_color="#aa3333", command=self.delete_selected).pack(side="left", padx=8) ctk.CTkButton(btns, text="打开选中任务的链接", command=self.open_selected_links).pack(side="left", padx=8) ctk.CTkButton(btns, text="标记今日处理", command=self.mark_selected_processed_today).pack(side="left", padx=8) ctk.CTkButton(btns, text="处理日历视图", command=self.open_calendar).pack(side="left", padx=8) # ctk.CTkButton(btns, text="编辑处理日期", command=self.add_processed_date_for_selected).pack(side="left", padx=8) # table frame table_frame = ctk.CTkFrame(self); table_frame.pack(fill="both", expand=True, padx=10, pady=6) headers = [h for _, h in COLUMNS] self.sheet = Sheet(table_frame, data=[[]], headers=headers, column_width=120, row_headers=False, height=560) # enable selection + text-select but disable editing self.sheet.enable_bindings(("single_select","row_select","column_select","cell_select","text_select","right_click_popup_menu","rc_select","copy","select_all","arrowkeys","column_width_resize")) self.sheet.disable_bindings(("editable","edit_cell","double_click_edit","paste","cut")) self.sheet.grid(row=0, column=0, sticky="nsew") table_frame.grid_rowconfigure(0, weight=1); table_frame.grid_columnconfigure(0, weight=1) # bind double click using extra_bindings if available try: self.sheet.extra_bindings([("double_click_cell", self._on_double_click_cell)]) except Exception: self.sheet.bind("", self._on_double_click_generic, add="+") # try binding header clicks via extra_bindings if available (try multiple tksheet event names) try: self.sheet.extra_bindings([("column_select", self._on_header_click)]) except Exception: print('[Error] bind _on_header_click failed!!!') # header/column clicks - generic # primary binding directly on sheet self.sheet.bind("", self._on_sheet_click_generic, add="+") # also listen to ButtonRelease as some versions use release for headers self.sheet.bind("", self._on_sheet_click_generic, add="+") # global binding as fallback to ensure header clicks are caught even if tksheet consumes the event self.bind_all("", self._on_root_click, add="+") self.bind_all("", self._on_root_click, add="+") # 指定列居中 align_center_cols = [] for i, (key, _) in enumerate(COLUMNS): if key in ('sid', 'priority', 'type', 'status', 'start_date', 'processed_today', 'last_processed', 'links_count', 'composite'): align_center_cols.append(i) self.sheet.align_columns(columns=align_center_cols, align="center") # displayed sids mapping (integers) self.displayed_sids = [] # debug: dump and bind sheet children to help catch header clicks if getattr(self, '_debug_tksheet', False): self.after(200, self._debug_dump_sheet) self.after(300, self._bind_sheet_children) # ---------- refresh table ---------- def refresh_table(self): text = self.search_var.get().strip() or None types = [self.type_listbox.get(i) for i in self.type_listbox.curselection()] or None statuses = [self.status_listbox.get(i) for i in self.status_listbox.curselection()] or None priorities = [self.priority_listbox.get(i) for i in self.priority_listbox.curselection()] or None rows = self.db.list_tasks(text_search=text, types=types, statuses=statuses, priorities=priorities) # sort rows according to current order_by / order_asc if getattr(self, 'order_by', None): col = self.order_by if col == "composite": rows.sort(key=lambda r: compute_composite_score(r), reverse=not self.order_asc) elif col in ("priority","type","status"): order_map = SORT_ORDER.get(col, {}) rows.sort(key=lambda r: order_map.get(r.get(col), 999), reverse=not self.order_asc) elif col == "last_processed": rows.sort(key=lambda r: (r.get("last_processed") or ""), reverse=not self.order_asc) elif col == "processed_today": today_iso = date.today().isoformat() rows.sort(key=lambda r: (today_iso in self.db.get_logs_for_task(r["sid"])), reverse=not self.order_asc) else: rows.sort(key=lambda r: r.get(col) or "", reverse=not self.order_asc) # compute composite scores and build table rows table = []; self.displayed_sids = [] processed_map = {} for r in rows: short_id = str(r.get("sid")) tid = r["sid"] links = json.loads(r.get("links") or "[]") links_count = len(links) composite = compute_composite_score(r) # check logs for today today_iso = date.today().isoformat() processed = False try: logs = self.db.get_logs_for_task(tid) processed = today_iso in logs except Exception: processed = False processed_map[tid] = processed processed_icon = PROCESSED_DISPLAY["yes"]["icon"] if processed else PROCESSED_DISPLAY["no"]["icon"] # Build row dynamically according to COLUMNS so reordering COLUMNS will reorder displayed values row = [] for key, _label in COLUMNS: if key == "sid": row.append(short_id) elif key == "title": row.append(r.get("title") or "") elif key == "brief": row.append(r.get("brief") or "") elif key == "priority": row.append((PRIORITY_DISPLAY.get(r.get("priority")) or {}).get("icon","") + " " + (r.get("priority") or "")) elif key == "type": row.append((TYPE_DISPLAY.get(r.get("type")) or {}).get("icon","") + " " + (r.get("type") or "")) elif key == "status": row.append((STATUS_DISPLAY.get(r.get("status")) or {}).get("icon","") + " " + (r.get("status") or "")) elif key == "processed_today": row.append(processed_icon) elif key == "last_processed": row.append(r.get("last_processed") or "") elif key == "start_date": row.append(r.get("start_date") or "") elif key == "links_count": row.append(str(links_count)) elif key == "notes": row.append(r.get("notes") or "") elif key == "composite": row.append(f"{composite:.4f}") else: # unknown key: fallback to raw value if present row.append(r.get(key) or "") table.append(row) self.displayed_sids.append(r["sid"]) # build arrow headers headers = [] arrow = '' for i, (key, label) in enumerate(COLUMNS): if self.order_by == key: if self.order_asc: headers.append(label + ' ▲') else: headers.append(label + ' ▼') else: headers.append(label) if not table: table = [[]] self.sheet.set_sheet_data(table, reset_col_positions=False, reset_row_positions=False) self.sheet.headers(headers) if getattr(self, "_debug_tksheet", False): self._debug_label_var.set(f"Last sort: {self.order_by},{'asc' if self.order_asc else 'desc' if self.order_by else 'none'}") # column widths try: self.sheet.column_width(column=COL_TITLE, width=320) self.sheet.column_width(column=COL_BRIEF, width=220) # notes index moved after adding last_processed column; notes is now a constant self.sheet.column_width(column=COL_NOTES, width=240) except Exception: pass # clear colors try: self.sheet.clear_cell_colors() except Exception: pass # 首先根据奇数偶数行设置背景色 for r_idx in range(len(self.displayed_sids)): if r_idx % 2 == 0: bg = ROWS_COLORS[0] else: bg = ROWS_COLORS[1] try: self.sheet.highlight_cells(row=r_idx, column='all', bg=bg) except Exception: try: self.sheet.set_row_bg(r_idx, bg) except Exception: pass # apply bg colors for priority/type/status cells (use COL_* constants) for r_idx, tid in enumerate(self.displayed_sids): task = self.db.get_task(tid) pr = task.get("priority"); ty = task.get("type"); st = task.get("status") # check if processed today using processed_map processed = processed_map.get(tid, False) if pr and pr in PRIORITY_DISPLAY: bg = PRIORITY_DISPLAY[pr]["bg"] try: self.sheet.highlight_cells(row=r_idx, column=COL_PRIORITY, bg=bg) except Exception: try: self.sheet.set_cell_bg(r_idx,COL_PRIORITY,bg) except Exception: pass if ty and ty in TYPE_DISPLAY: bg = TYPE_DISPLAY[ty]["bg"] try: self.sheet.highlight_cells(row=r_idx, column=COL_TYPE, bg=bg) except Exception: try: self.sheet.set_cell_bg(r_idx,COL_TYPE,bg) except Exception: pass if st and st in STATUS_DISPLAY: bg = STATUS_DISPLAY[st]["bg"] try: self.sheet.highlight_cells(row=r_idx, column=COL_STATUS, bg=bg) except Exception: try: self.sheet.set_cell_bg(r_idx,COL_STATUS,bg) except Exception: pass # processed today column: use COL_PROCESSED_TODAY constant if processed: bg = PROCESSED_DISPLAY["yes"]["bg"] else: bg = PROCESSED_DISPLAY["no"]["bg"] try: self.sheet.highlight_cells(row=r_idx, column=COL_PROCESSED_TODAY, bg=bg) except Exception: try: self.sheet.set_cell_bg(r_idx,COL_PROCESSED_TODAY,bg) except Exception: pass # try auto row size try: self.sheet.set_all_cell_sizes_to_text() except Exception: pass def _clear_filters(self): self.search_var.set("") self.type_listbox.selection_clear(0, "end") self.status_listbox.selection_clear(0, "end") self.priority_listbox.selection_clear(0, "end") self.order_by = "composite"; self.order_asc = False self.refresh_table() def _clear_sort(self): self.order_by = None self.order_asc = False self.refresh_table() # ---------- selection helpers ---------- def _get_first_selected_row_index(self): sel = None try: # Prefer cell selections so single-cell selection works sel = self.sheet.get_selected_cells() except Exception: try: sel = self.sheet.get_selected_rows() except Exception: sel = None if not sel: return None if isinstance(sel, set): sel_list = list(sel) elif isinstance(sel, (list, tuple)): sel_list = list(sel) else: try: sel_list = list(sel) except Exception: sel_list = [sel] if not sel_list: return None first = sel_list[0] if isinstance(first, (list, tuple)) and len(first)>0: return int(first[0]) return int(first) def add_task(self): def after_save(): self.refresh_table() TaskEditor(self, self.db, task_id=None, on_save=after_save) def edit_selected(self): r = self._get_first_selected_row_index() if r is None: messagebox.showinfo("Info", "Select a row") return if r < 0 or r >= len(self.displayed_sids): return tid = self.displayed_sids[r] def after_save(): self.refresh_table() TaskEditor(self, self.db, task_id=tid, on_save=after_save) def delete_selected(self): r = self._get_first_selected_row_index() if r is None: return tid = self.displayed_sids[r] if messagebox.askyesno("Confirm", "Delete selected task?"): self.db.delete_task(tid); self.refresh_table() def open_selected_links(self): r = self._get_first_selected_row_index() if r is None: return tid = self.displayed_sids[r] row = self.db.get_task(tid) links = json.loads(row.get("links") or "[]") if not links: messagebox.showinfo("No links", "No links found"); return for u in links: webbrowser.open(u) def mark_selected_processed_today(self): r = self._get_first_selected_row_index() if r is None: messagebox.showinfo("Info","Select a row"); return tid = self.displayed_sids[r] today = date.today().isoformat() ok = self.db.add_log(tid, today) if ok: messagebox.showinfo("OK", "Marked today"); self.refresh_table() else: messagebox.showinfo("Info", "Already marked") def add_processed_date_for_selected(self): r = self._get_first_selected_row_index() if r is None: messagebox.showinfo("Info","Select a row"); return tid = self.displayed_sids[r] win = tk.Toplevel(self); win.title("Pick Date") cal = Calendar(win, selectmode="day"); cal.pack(padx=8, pady=8) def ok(): ds = cal.selection_get().isoformat() ok = self.db.add_log(tid, ds) if ok: messagebox.showinfo("OK","Added"); self.refresh_table() else: messagebox.showinfo("Info","Already exists") win.destroy() tk.Button(win, text="OK", command=ok).pack(pady=6) # double click handlers def _on_double_click_cell(self, event): print('_on_double_click_cell') r = getattr(event, "row", None) if r is None: # fallback: use selection r = self._get_first_selected_row_index() if r is None: return self.sheet.set_selected_rows([r]); self.edit_selected() def _on_double_click_generic(self, event): try: if hasattr(self.sheet, "get_clicked_cell"): rc = self.sheet.get_clicked_cell(event) if rc and isinstance(rc, (list,tuple)): r = rc[0]; if r is None or r < 0: return self.sheet.set_selected_rows([r]); self.edit_selected(); return except Exception: pass r = self._get_first_selected_row_index() if r is not None: self.edit_selected() def _on_header_click(self, event): # This is used for tksheet extra_bindings header clicks (different tksheet versions send different payloads) print("_on_header_click") try: col_idx = getattr(event, "selected", None).column # col_idx = self._resolve_col_index(col_idx, event) if col_idx is None: return self._toggle_sort_by_col(col_idx) except Exception: # swallow - no-op pass # header click generic for sorting def _on_sheet_click_generic(self, event): try: if hasattr(self.sheet, "get_clicked_cell"): rc = self.sheet.get_clicked_cell(event) # rc can be tuple/list or dict in different tksheet versions row_idx = None; col_idx = None if isinstance(rc, (list, tuple)) and len(rc) >= 2: row_idx, col_idx = rc[0], rc[1] elif isinstance(rc, dict): row_idx = rc.get("row") or rc.get("r") col_idx = rc.get("column") or rc.get("col") or rc.get("c") # Header is indicated by negative/None row index try: header_height = getattr(self.sheet, "header_height", 26) # consider header if row_idx is None/negative OR y within header height is_header = (row_idx is None) or (int(row_idx) < 0) or (hasattr(event, 'y') and event.y <= header_height) if is_header: # ensure column is an int col_idx = self._resolve_col_index(col_idx, event) if col_idx is None: # fallback to column mapping by position pass else: if getattr(self, "_debug_tksheet", False): dbg = f"_on_sheet_click_generic header rc={rc} col={col_idx} x={getattr(event,'x',None)} y={getattr(event,'y',None)}" try: self._debug_label_var.set(dbg) except Exception: pass self._toggle_sort_by_col(col_idx) return # if not header and we have a valid row index, ensure the row is selected (so edit button works) if row_idx is not None and isinstance(row_idx, int) and row_idx >= 0: try: self.sheet.set_selected_rows([row_idx]) except Exception: try: # fallback: set_selected_cells if needed self.sheet.set_selected_cells([(row_idx, col_idx)]) except Exception: pass except Exception: # ignore and continue pass except Exception: pass # fallback: older tksheet versions might not provide get_clicked_cell; try to map from event.x try: # compute column by iterating through column positions if available if hasattr(self.sheet, "col_positions") or hasattr(self.sheet, "column_positions"): # legacy compatibility # attempt to use internal method to derive column index # col_positions is a list with left edge x positions; we attempt an approximate mapping px = event.x col_positions = getattr(self.sheet, "col_positions", None) or getattr(self.sheet, "column_positions", None) if col_positions: # log debug if getattr(self, "_debug_tksheet", False): print("[DEBUG] col_positions type:", type(col_positions), "value:", col_positions) if col_positions and isinstance(col_positions, (list, tuple)): for ci in range(len(col_positions)): left = col_positions[ci] right = col_positions[ci+1] if ci+1 < len(col_positions) else left + 1 try: if px >= left and px < right: # header clicked if event.y near top if event.y <= getattr(self.sheet, "header_height", 26): if getattr(self, "_debug_tksheet", False): dbg = f"_on_sheet_click_generic pixel map header col={ci} x={getattr(event,'x',None)} y={getattr(event,'y',None)}" try: self._debug_label_var.set(dbg) except Exception: pass print("[DEBUG] _on_sheet_click_generic:", dbg) self._toggle_sort_by_col(ci) return except Exception: continue except Exception: pass def _resolve_col_index(self, col_val, event=None): """Resolve various representations of a column index returned by tksheet into an integer index. col_val can be int, str, or None. If None, attempt to map from event.x and column positions. Returns an integer column index or None if unresolved. """ # direct int try: if isinstance(col_val, int): return col_val if isinstance(col_val, str) and col_val.isdigit(): return int(col_val) # maybe the string is a header label (e.g., 'Title' or 'Composite'): map it if isinstance(col_val, str): val = col_val.strip().lower() for ci, (key, label) in enumerate(COLUMNS): if val == label.lower() or val == (label + ' ▲').lower() or val == (label + ' ▼').lower(): return ci # partial match if val in label.lower(): return ci except Exception: pass # attempt to map based on pixel x using column positions if event is not None and hasattr(event, 'x'): try: px = event.x col_positions = getattr(self.sheet, 'col_positions', None) or getattr(self.sheet, 'column_positions', None) if col_positions and isinstance(col_positions, (list, tuple)): for ci in range(len(col_positions)): left = col_positions[ci] right = col_positions[ci+1] if ci+1 < len(col_positions) else left + 100 if px >= left and px < right: return ci except Exception: pass return None def _on_root_click(self, event): # Global fallback capture. Determine whether click is within the sheet header area try: sx = self.sheet.winfo_rootx(); sy = self.sheet.winfo_rooty() sw = self.sheet.winfo_width(); sh = self.sheet.winfo_height() rx = event.x_root; ry = event.y_root if rx < sx or rx > sx + sw or ry < sy or ry > sy + sh: return # compute local coords lx = rx - sx; ly = ry - sy header_height = getattr(self.sheet, 'header_height', 26) if ly <= header_height: # header click; compute column index using col_positions or resolve helper col_idx = None col_positions = getattr(self.sheet, 'col_positions', None) or getattr(self.sheet, 'column_positions', None) if col_positions and isinstance(col_positions, (list, tuple)): for ci in range(len(col_positions)): left = col_positions[ci] right = col_positions[ci+1] if ci+1 < len(col_positions) else left + 100 if lx >= left and lx < right: col_idx = ci; break if col_idx is None: # try resolve via helper using event object (sheet.get_clicked_cell will consume event if used) try: rc = None if hasattr(self.sheet, 'get_clicked_cell'): rc = self.sheet.get_clicked_cell(event) # attempt to resolve using rc if rc is not None: if isinstance(rc, (list, tuple)) and len(rc) >= 2: col_idx = rc[1] elif isinstance(rc, dict): col_idx = rc.get('column') or rc.get('col') or rc.get('c') col_idx = self._resolve_col_index(col_idx, event) except Exception: col_idx = None if col_idx is not None: if getattr(self, '_debug_tksheet', False): dbg = f"_on_root_click: header click at local {lx},{ly} col {col_idx}" try: self._debug_label_var.set(dbg) except Exception: pass print('[DEBUG] _on_root_click:', dbg) self._toggle_sort_by_col(int(col_idx)) except Exception: pass def _bind_sheet_children(self): try: children = self.sheet.winfo_children() bound = [] if getattr(self, '_debug_tksheet', False): print('[DEBUG] _bind_sheet_children found', len(children), 'children') def bind_recursive(w): try: if hasattr(w, 'bind'): w.bind('', self._on_child_click, add='+') w.bind('', self._on_child_click, add='+') bound.append(w) except Exception: pass for c in w.winfo_children(): bind_recursive(c) for ch in children: bind_recursive(ch) # also try to bind to any attribute widgets with names that suggest header/canvas for attr in dir(self.sheet): low = attr.lower() if any(k in low for k in ('header', 'head', 'canvas', 'table', 'col', 'column')): try: val = getattr(self.sheet, attr) if hasattr(val, 'bind'): val.bind('', self._on_child_click, add='+') val.bind('', self._on_child_click, add='+') bound.append(val) except Exception: pass if getattr(self, '_debug_tksheet', False): print('[DEBUG] Bound to', len(bound), 'widgets') try: print(' Bound widgets:') for w in bound: try: print(' -', w, w.winfo_class(), getattr(w, 'winfo_name', lambda: None)()) except Exception: print(' -', w) except Exception: pass except Exception: pass def _on_child_click(self, event): # Redirect child click events to header handler if click is within header try: # compute sheet-local coords sx = self.sheet.winfo_rootx(); sy = self.sheet.winfo_rooty() rx = event.x_root; ry = event.y_root lx = rx - sx; ly = ry - sy header_height = getattr(self.sheet, 'header_height', 26) if ly <= header_height: # map to a column index via pixel positions col_positions = getattr(self.sheet, 'col_positions', None) or getattr(self.sheet, 'column_positions', None) col_idx = None if col_positions and isinstance(col_positions, (list, tuple)): px = lx for ci in range(len(col_positions)): left = col_positions[ci] right = col_positions[ci+1] if ci+1 < len(col_positions) else left + 100 if px >= left and px < right: col_idx = ci; break if col_idx is None and hasattr(self.sheet, 'get_clicked_cell'): try: rc = self.sheet.get_clicked_cell(event) if isinstance(rc, (list, tuple)) and len(rc) >= 2: col_idx = rc[1] elif isinstance(rc, dict): col_idx = rc.get('column') or rc.get('col') or rc.get('c') except Exception: col_idx = None col_idx = self._resolve_col_index(col_idx, event) if col_idx is not None: if getattr(self, '_debug_tksheet', False): dbg = f'_on_child_click header col={col_idx} px={lx} py={ly}' try: self._debug_label_var.set(dbg) except Exception: pass print('[DEBUG] _on_child_click:', dbg) self._toggle_sort_by_col(int(col_idx)) except Exception: pass def _debug_dump_sheet(self): try: print('[DEBUG] Dumping sheet widget structure...') print('Sheet attributes:') try: print(' dir(sheet) len', len(dir(self.sheet))) except Exception: pass try: keys = [k for k in dir(self.sheet) if not k.startswith('_')] print(' public attrs count:', len(keys)) except Exception: pass try: print(' sheet.__dict__ keys:', list(self.sheet.__dict__.keys())) except Exception: pass try: print(' sheet child widgets:') for c in self.sheet.winfo_children(): print(' -', c, c.winfo_class(), c.winfo_name(), 'w/h', c.winfo_width(), c.winfo_height()) except Exception: pass except Exception: pass def _toggle_sort_by_col(self, col_idx): if col_idx is None or col_idx < 0 or col_idx >= len(COLUMNS): return # prevent duplicate toggling if handlers fire twice (root and sheet) now = time.time() if isinstance(self._last_header_toggle, tuple): last_col, last_time = self._last_header_toggle if last_col == col_idx and now - last_time < 0.25: # ignore repeated toggles within 250ms for same column if getattr(self, '_debug_tksheet', False): print('[DEBUG] _toggle_sort_by_col: skipping duplicate toggle for col', col_idx) return # record this toggle self._last_header_toggle = (col_idx, now) col_name = COLUMNS[col_idx][0] if col_name == "composite": col_key = "composite" else: col_key = col_name # 3-state cycle: if new column -> asc; if same and asc True -> desc; if same and asc False -> clear sort if self.order_by != col_key: self.order_by = col_key # composite defaults to descending, other columns default to ascending self.order_asc = False if col_key == "composite" else True else: if self.order_asc: self.order_asc = False else: # clear sorting state (back to no sort) self.order_by = None self.order_asc = False # re-query and refresh according to updated state self.refresh_table() if getattr(self, '_debug_tksheet', False): dbg = f"_toggle_sort_by_col: col={col_idx} key={col_key} order_asc={self.order_asc}" try: self._debug_label_var.set(dbg) except Exception: pass print('[DEBUG]', dbg) def _sort_and_refresh(self): # Sorting is handled centrally by refresh_table; just refresh here to apply the current sort state self.refresh_table() # templates def save_template(self): name = simpledialog.askstring("Template name", "Enter template name") if not name: return cfg = { "search": self.search_var.get().strip(), "types":[self.type_listbox.get(i) for i in self.type_listbox.curselection()], "statuses":[self.status_listbox.get(i) for i in self.status_listbox.curselection()], "priorities":[self.priority_listbox.get(i) for i in self.priority_listbox.curselection()], "order_by": self.order_by, "order_asc": self.order_asc } self.templates.add(name, cfg) self.template_combo.configure(values=["(none)"] + self.templates.list_names()) self.template_combo.set(name) messagebox.showinfo("Saved", f"Template '{name}' saved") def load_template(self): name = self.template_combo.get() if not name or name=="(none)": return cfg = self.templates.get(name) if not cfg: messagebox.showerror("Err","Template not found"); return self.search_var.set(cfg.get("search","")) def apply_selection(lb, vals): lb.selection_clear(0,"end") for i in range(lb.size()): if lb.get(i) in vals: lb.selection_set(i) apply_selection(self.type_listbox, cfg.get("types",[])) apply_selection(self.status_listbox, cfg.get("statuses",[])) apply_selection(self.priority_listbox, cfg.get("priorities",[])) self.order_by = cfg.get("order_by","composite"); self.order_asc = cfg.get("order_asc",False) self.refresh_table() def delete_template(self): name = self.template_combo.get() if not name or name=="(none)": return if messagebox.askyesno("确认", f"删除模板 '{name}'?"): self.templates.delete(name); self.template_combo.configure(values=["(none)"]+self.templates.list_names()); self.template_combo.set("(none)"); messagebox.showinfo("Deleted","Template deleted") def open_calendar(self): win = ctk.CTkToplevel(self); win.title("Calendar View"); win.geometry("720x520") frm = ctk.CTkFrame(win); frm.pack(fill="both", expand=True, padx=12, pady=12) cal = Calendar(frm, selectmode="day"); cal.pack(side="left", padx=8, pady=8) right = ctk.CTkFrame(frm); right.pack(side="left", fill="both", expand=True, padx=8, pady=8) ctk.CTkLabel(right, text="Tasks processed on selected day:").pack(anchor="w") lb = tk.Listbox(right); lb.pack(fill="both", expand=True) def on_select(e=None): ds = cal.selection_get().isoformat() tasks = self.db.get_tasks_on_date(ds) lb.delete(0,"end") for t in sorted(tasks, key=lambda r: compute_composite_score(r), reverse=True): lb.insert("end", f"{t.get('sid')} {t['title']}") cal.bind("<>", on_select) def open_task(): sel = lb.curselection() if not sel: return s = lb.get(sel[0]); short = s.split()[0] if short.isdigit(): cur = self.db.conn.execute("SELECT sid FROM tasks WHERE sid=?", (int(short),)) row = cur.fetchone() if not row: messagebox.showerror("Err","Not found"); return TaskEditor(win, self.db, task_id=row["sid"], on_save=on_select) else: # try title search cur = self.db.conn.execute("SELECT sid FROM tasks WHERE title LIKE ? LIMIT 1", (f"%{short}%",)) row = cur.fetchone() if not row: messagebox.showerror("Err","Not found"); return TaskEditor(win, self.db, task_id=row["sid"], on_save=on_select) ctk.CTkButton(right, text="Open Selected Task", command=open_task).pack(pady=6) on_select() # run / refresh def run(self): self.mainloop() # ----------------------- bootstrap & start ----------------------- if __name__ == "__main__": app = TaskManagerApp() app.refresh_table() app.run()