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.
1596 lines
69 KiB
1596 lines
69 KiB
import os
|
|
import json
|
|
import math
|
|
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
|
|
import tkinter.font as tkfont
|
|
|
|
# ----------------------- CONFIG -----------------------
|
|
# region 配置
|
|
VERSION = "v1.0.5"
|
|
|
|
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"},
|
|
"小问题": {"icon": "🔔", "bg": "#f6a746"},
|
|
}
|
|
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, "小问题" : 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, "小问题": 1.0},
|
|
"status": {"待处理": 0.9, "进行中": 1.0, "保持跟进": 0.15, "搁置": 0.1, "取消": 0.0, "已完成": 0.0},
|
|
# age factor multiplier (per day)
|
|
"age_factor": 0.05
|
|
}
|
|
|
|
# Columns (for sheet) 这里的顺序即列显示顺序
|
|
COLUMNS = [
|
|
("processed_today", "当日处理"),
|
|
("sid", "ID"),
|
|
("type", "类型"),
|
|
("status", "状态"),
|
|
("priority", "优先级"),
|
|
("deadline", "截止日期"),
|
|
("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")
|
|
COL_DEADLINE = COL_INDEX.get("deadline")
|
|
|
|
# 截止日期颜色,距离截止日期的时间小于哪个值就是哪个颜色
|
|
DEADLINE_COLOR = [
|
|
(0, "#fb0000"),
|
|
(1, "#ff6f22"),
|
|
(2, "#ff904b"),
|
|
(3, "#b2ff77"),
|
|
(4, "#73bc75"),
|
|
(10, "#83d6ff"),
|
|
(15, "#bdd8e9"),
|
|
]
|
|
# 保持跟进、搁置超出日期时的颜色
|
|
PAUSE_DEADLINE_COLOR = "#fbffa9"
|
|
# 默认颜色
|
|
DEFAULT_DEADLINE_COLOR = "#ffffff"
|
|
|
|
# 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,
|
|
deadline 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 "deadline" not in cols:
|
|
adds.append(("deadline", "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, deadline, 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"),
|
|
task.get("deadline"),
|
|
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=?, deadline=?, 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"),
|
|
task.get("deadline"),
|
|
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_from_start = 0
|
|
try:
|
|
if start_date:
|
|
dt = datetime.fromisoformat(start_date)
|
|
# convert to local naive days
|
|
days_from_start = (datetime.now(dt.tzinfo or timezone.utc).date() - dt.date()).days
|
|
if days_from_start < 0:
|
|
days_from_start = 0
|
|
except Exception:
|
|
days_from_start = 0
|
|
# 距离截止日期的时间
|
|
deadline = task_row.get("deadline")
|
|
have_deadline = False
|
|
days_to_deadline = 0
|
|
try:
|
|
if deadline:
|
|
dt_deadline = datetime.fromisoformat(deadline)
|
|
days_to_deadline = (dt_deadline.date() - datetime.now(dt_deadline.tzinfo or timezone.utc).date()).days
|
|
have_deadline = True
|
|
except Exception:
|
|
have_deadline = False
|
|
# 各种因素分数
|
|
ddl_score = 1 + math.exp(-(0.3 + days_to_deadline / 3.0)) if have_deadline else 0.9
|
|
d_score = COMPOSITE_WEIGHTS.get("age_factor", 0.1) * days_from_start + 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)
|
|
|
|
# 跟进和搁置状态不考虑截止日期
|
|
state = task_row.get("status")
|
|
if state in ["保持跟进", "搁置"]:
|
|
ddl_score = 1.0
|
|
|
|
# 计算综合优先级分数
|
|
score = p_score * t_score * s_score * d_score * ddl_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.deadline_var = ctk.StringVar(value="")
|
|
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)
|
|
|
|
# deadline
|
|
ctk.CTkLabel(frm, text="Deadline (YYYY-MM-DD)").grid(row=10, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0))
|
|
ctk.CTkEntry(frm, textvariable=self.deadline_var).grid(row=11, column=0, columnspan=3, sticky="ew", padx=pad)
|
|
|
|
# links
|
|
ctk.CTkLabel(frm, text="Links (one per line)").grid(row=12, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0))
|
|
self.links_text = ctk.CTkTextbox(frm, height=80)
|
|
self.links_text.grid(row=13, column=0, columnspan=3, sticky="nsew", padx=pad)
|
|
|
|
# logs (processed dates)
|
|
ctk.CTkLabel(frm, text="Processed Dates (handling records)").grid(row=14, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0))
|
|
logs_frame = ctk.CTkFrame(frm)
|
|
logs_frame.grid(row=15, 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=16, column=0, columnspan=3, sticky="nsew", padx=pad, pady=(4,0))
|
|
|
|
# buttons
|
|
btn_row = ctk.CTkFrame(frm)
|
|
btn_row.grid(row=17, 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(13, weight=0)
|
|
frm.grid_rowconfigure(16, 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 "")
|
|
self.deadline_var.set(row.get("deadline") 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(),
|
|
"deadline": self.deadline_var.get().strip() or "",
|
|
"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"],
|
|
"deadline": data["deadline"],
|
|
"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"],
|
|
"deadline": data["deadline"],
|
|
"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
|
|
|
|
# 调整单元格宽度所需参数
|
|
self._prev_col_widths = {}
|
|
self._resized_column = 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)
|
|
|
|
# ===============================
|
|
# 各种绑定
|
|
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:
|
|
self.sheet.extra_bindings([("column_select", self._on_header_click)])
|
|
except Exception as e:
|
|
print("[Warn] column_select not supported:", e)
|
|
|
|
try:
|
|
self.sheet.extra_bindings([("column_width_resize", self._on_column_resize)])
|
|
except Exception as e:
|
|
print("[Warn] column_width_resize not supported:", e)
|
|
|
|
self.bind_all("<ButtonRelease-1>", self._on_column_resize_end, add="+")
|
|
|
|
self.sheet.bind("<Button-1>", self._on_sheet_click_generic, add="+")
|
|
self.sheet.bind("<ButtonRelease-1>", self._on_sheet_click_generic, add="+")
|
|
|
|
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', 'deadline'):
|
|
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)
|
|
elif col == "deadline":
|
|
# Treat empty/null deadlines as greater than any date so they appear last when sorting ascending
|
|
def _deadline_key(r):
|
|
d = r.get("deadline")
|
|
if not d:
|
|
# missing deadline -> mark as 'empty' (1) and None for tie
|
|
return (1, None)
|
|
try:
|
|
dt = datetime.fromisoformat(d)
|
|
return (0, dt)
|
|
except Exception:
|
|
# fallback to string compare (ISO-like strings sort correctly)
|
|
return (0, d)
|
|
rows.sort(key=_deadline_key, 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 == "deadline":
|
|
row.append(r.get("deadline") 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")
|
|
ddl = task.get("deadline")
|
|
# 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
|
|
if ddl and st not in ["已完成", "取消"]:
|
|
try:
|
|
dt_deadline = datetime.fromisoformat(ddl)
|
|
days_to_deadline = (dt_deadline.date() - datetime.now(dt_deadline.tzinfo or timezone.utc).date()).days
|
|
bg = DEFAULT_DEADLINE_COLOR
|
|
for (d, c) in DEADLINE_COLOR:
|
|
if days_to_deadline <= d:
|
|
bg = c if st not in ["保持跟进", "搁置"] else PAUSE_DEADLINE_COLOR
|
|
break
|
|
try: self.sheet.highlight_cells(row=r_idx, column=COL_DEADLINE, bg=bg)
|
|
except Exception:
|
|
try: self.sheet.set_cell_bg(r_idx,COL_DEADLINE,bg)
|
|
except Exception: pass
|
|
except Exception:
|
|
pass
|
|
else:
|
|
bg = DEFAULT_DEADLINE_COLOR
|
|
try: self.sheet.highlight_cells(row=r_idx, column=COL_DEADLINE, bg=bg)
|
|
except Exception:
|
|
try: self.sheet.set_cell_bg(r_idx,COL_DEADLINE,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.refresh()
|
|
self.sheet.set_all_cell_sizes_to_text()
|
|
except Exception:
|
|
pass
|
|
|
|
# reset column width cache
|
|
self._init_column_width_cache()
|
|
|
|
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
|
|
|
|
def _on_column_resize(self, event):
|
|
new_w_list = self.sheet.get_column_widths(0)
|
|
for col in range(self.sheet.get_total_columns()):
|
|
new_w = new_w_list[col]
|
|
old_w = self._prev_col_widths[col]
|
|
if new_w != old_w:
|
|
self._prev_col_widths[col] = new_w
|
|
self._resized_column = col
|
|
break
|
|
|
|
def _on_column_resize_end(self, event):
|
|
if self._resized_column is None:
|
|
return
|
|
|
|
col = self._resized_column
|
|
self._resized_column = None
|
|
self._recalc_column_row_heights(col)
|
|
|
|
# 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()
|
|
|
|
# 调整单元格参数
|
|
def _init_column_width_cache(self):
|
|
self._prev_col_widths = self.sheet.get_column_widths(0)
|
|
|
|
def _calc_text_lines(self, text, col_width, tk_font):
|
|
if not text:
|
|
return 1
|
|
|
|
lines = 0
|
|
for paragraph in str(text).split("\n"):
|
|
current_width = 0
|
|
for ch in paragraph:
|
|
ch_width = tk_font.measure(ch)
|
|
if current_width + ch_width > col_width:
|
|
lines += 1
|
|
current_width = ch_width
|
|
else:
|
|
current_width += ch_width
|
|
lines += 1 # 每个段落至少一行
|
|
|
|
return max(lines, 1)
|
|
|
|
def _recalc_column_row_heights(self, col):
|
|
font_desc = self.sheet.font()
|
|
tk_font = tkfont.Font(font=font_desc)
|
|
|
|
line_height = tk_font.metrics("linespace")
|
|
|
|
col_widths = self.sheet.get_column_widths(0)
|
|
col_num = self.sheet.get_total_columns()
|
|
|
|
for row in range(self.sheet.get_total_rows()):
|
|
max_height = 0
|
|
|
|
for col in range(col_num):
|
|
text = self.sheet.get_cell_data(row, col)
|
|
if not text:
|
|
continue
|
|
|
|
col_width = col_widths[col]
|
|
lines = self._calc_text_lines(text, col_width, tk_font)
|
|
|
|
height = lines * line_height + 6
|
|
height = min(max(height, 20), 100000)
|
|
|
|
if height > max_height:
|
|
max_height = height
|
|
|
|
self.sheet.row_height(row, max_height)
|
|
|
|
self.sheet.refresh()
|
|
|
|
|
|
# run / refresh
|
|
def run(self):
|
|
self.mainloop()
|
|
|
|
# ----------------------- bootstrap & start -----------------------
|
|
if __name__ == "__main__":
|
|
app = TaskManagerApp()
|
|
app.refresh_table()
|
|
app.run()
|
|
|