简易任务管理器,管理手头上的工作
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

1421 lines
63 KiB

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("<Double-1>", 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("<Button-1>", self._on_sheet_click_generic, add="+")
# also listen to ButtonRelease as some versions use release for headers
self.sheet.bind("<ButtonRelease-1>", 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("<Button-1>", self._on_root_click, add="+")
self.bind_all("<ButtonRelease-1>", 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('<Button-1>', self._on_child_click, add='+')
w.bind('<ButtonRelease-1>', 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('<Button-1>', self._on_child_click, add='+')
val.bind('<ButtonRelease-1>', 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("<<CalendarSelected>>", 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()