|
|
@ -1,5 +1,6 @@ |
|
|
import os |
|
|
import os |
|
|
import json |
|
|
import json |
|
|
|
|
|
import math |
|
|
import sqlite3 |
|
|
import sqlite3 |
|
|
import webbrowser |
|
|
import webbrowser |
|
|
from datetime import datetime, date, timezone |
|
|
from datetime import datetime, date, timezone |
|
|
@ -9,10 +10,11 @@ from tkinter import messagebox, simpledialog |
|
|
import customtkinter as ctk |
|
|
import customtkinter as ctk |
|
|
from tksheet import Sheet |
|
|
from tksheet import Sheet |
|
|
from tkcalendar import Calendar |
|
|
from tkcalendar import Calendar |
|
|
|
|
|
import tkinter.font as tkfont |
|
|
|
|
|
|
|
|
# ----------------------- CONFIG ----------------------- |
|
|
# ----------------------- CONFIG ----------------------- |
|
|
# region 配置 |
|
|
# region 配置 |
|
|
VERSION = "v1.0.0" |
|
|
VERSION = "v1.0.6" |
|
|
|
|
|
|
|
|
DB_PATH = "tasks.db" # 数据库文件路径 |
|
|
DB_PATH = "tasks.db" # 数据库文件路径 |
|
|
TEMPLATES_PATH = "templates.json" # 检索模板文件路径 |
|
|
TEMPLATES_PATH = "templates.json" # 检索模板文件路径 |
|
|
@ -29,7 +31,7 @@ CTK_COLOR_THEME = "blue" |
|
|
|
|
|
|
|
|
# Options |
|
|
# Options |
|
|
PRIORITY_OPTIONS = ["SSS", "SS", "S", "A", "B", "C", "D", "E"] |
|
|
PRIORITY_OPTIONS = ["SSS", "SS", "S", "A", "B", "C", "D", "E"] |
|
|
TYPE_OPTIONS = ["Bug", "需求", "其他"] |
|
|
TYPE_OPTIONS = ["Bug", "需求", "其他", "小问题"] |
|
|
STATUS_OPTIONS = ["待处理", "进行中", "已完成", "保持跟进", "搁置", "取消"] |
|
|
STATUS_OPTIONS = ["待处理", "进行中", "已完成", "保持跟进", "搁置", "取消"] |
|
|
|
|
|
|
|
|
# Display sets (icon + background) |
|
|
# Display sets (icon + background) |
|
|
@ -47,6 +49,7 @@ TYPE_DISPLAY = { |
|
|
"Bug": {"icon": "🐞", "bg": "#ed7e7e"}, |
|
|
"Bug": {"icon": "🐞", "bg": "#ed7e7e"}, |
|
|
"需求": {"icon": "✨", "bg": "#a0e9c4"}, |
|
|
"需求": {"icon": "✨", "bg": "#a0e9c4"}, |
|
|
"其他": {"icon": "📝", "bg": "#c1d9fe"}, |
|
|
"其他": {"icon": "📝", "bg": "#c1d9fe"}, |
|
|
|
|
|
"小问题": {"icon": "🔔", "bg": "#f6a746"}, |
|
|
} |
|
|
} |
|
|
STATUS_DISPLAY = { |
|
|
STATUS_DISPLAY = { |
|
|
"待处理": {"icon": "⏳", "bg": "#f7e086"}, |
|
|
"待处理": {"icon": "⏳", "bg": "#f7e086"}, |
|
|
@ -71,17 +74,17 @@ PROCESSED_DISPLAY = { |
|
|
# SORT ORDER (Rule A) - used for table-column sorting (integer ranks) |
|
|
# SORT ORDER (Rule A) - used for table-column sorting (integer ranks) |
|
|
SORT_ORDER = { |
|
|
SORT_ORDER = { |
|
|
"priority": {"SSS": 8, "SS": 7, "S": 6, "A": 5, "B": 4, "C": 3, "D": 2, "E": 1}, |
|
|
"priority": {"SSS": 8, "SS": 7, "S": 6, "A": 5, "B": 4, "C": 3, "D": 2, "E": 1}, |
|
|
"type": {"Bug": 3, "需求": 2, "其他": 1}, |
|
|
"type": {"Bug": 3, "需求": 2, "其他": 1, "小问题" : 1}, |
|
|
"status": {"待处理": 6, "进行中": 5, "保持跟进": 4, "搁置": 3, "取消": 2, "已完成": 1} |
|
|
"status": {"待处理": 6, "进行中": 5, "保持跟进": 4, "搁置": 3, "取消": 2, "已完成": 1} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
# COMPOSITE WEIGHTS (Rule B) - used in composite score calculation |
|
|
# COMPOSITE WEIGHTS (Rule B) - used in composite score calculation |
|
|
COMPOSITE_WEIGHTS = { |
|
|
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}, |
|
|
"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}, |
|
|
"type": {"Bug": 1.5, "需求": 1.0, "其他": 1.0, "小问题": 1.0}, |
|
|
"status": {"待处理": 0.9, "进行中": 1.0, "保持跟进": 0.1, "搁置": 0.1, "取消": 0.0, "已完成": 0.0}, |
|
|
"status": {"待处理": 0.9, "进行中": 1.0, "保持跟进": 0.15, "搁置": 0.1, "取消": 0.0, "已完成": 0.0}, |
|
|
# age factor multiplier (per day) |
|
|
# age factor multiplier (per day) |
|
|
"age_factor": 0.1 |
|
|
"age_factor": 0.05 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
# Columns (for sheet) 这里的顺序即列显示顺序 |
|
|
# Columns (for sheet) 这里的顺序即列显示顺序 |
|
|
@ -91,6 +94,7 @@ COLUMNS = [ |
|
|
("type", "类型"), |
|
|
("type", "类型"), |
|
|
("status", "状态"), |
|
|
("status", "状态"), |
|
|
("priority", "优先级"), |
|
|
("priority", "优先级"), |
|
|
|
|
|
("deadline", "截止日期"), |
|
|
("composite", "综合优先级"), |
|
|
("composite", "综合优先级"), |
|
|
("title", "标题"), |
|
|
("title", "标题"), |
|
|
("brief", "简介"), |
|
|
("brief", "简介"), |
|
|
@ -115,6 +119,22 @@ COL_START_DATE = COL_INDEX.get("start_date") |
|
|
COL_LINKS_COUNT = COL_INDEX.get("links_count") |
|
|
COL_LINKS_COUNT = COL_INDEX.get("links_count") |
|
|
COL_NOTES = COL_INDEX.get("notes") |
|
|
COL_NOTES = COL_INDEX.get("notes") |
|
|
COL_COMPOSITE = COL_INDEX.get("composite") |
|
|
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 |
|
|
# endregion |
|
|
|
|
|
|
|
|
@ -141,6 +161,7 @@ class TaskDB: |
|
|
type TEXT, |
|
|
type TEXT, |
|
|
status TEXT, |
|
|
status TEXT, |
|
|
start_date TEXT, |
|
|
start_date TEXT, |
|
|
|
|
|
deadline TEXT, |
|
|
links TEXT, |
|
|
links TEXT, |
|
|
notes TEXT, |
|
|
notes TEXT, |
|
|
updated_at TEXT |
|
|
updated_at TEXT |
|
|
@ -178,6 +199,8 @@ class TaskDB: |
|
|
adds.append(("status", "TEXT")) |
|
|
adds.append(("status", "TEXT")) |
|
|
if "start_date" not in cols: |
|
|
if "start_date" not in cols: |
|
|
adds.append(("start_date", "TEXT")) |
|
|
adds.append(("start_date", "TEXT")) |
|
|
|
|
|
if "deadline" not in cols: |
|
|
|
|
|
adds.append(("deadline", "TEXT")) |
|
|
if "links" not in cols: |
|
|
if "links" not in cols: |
|
|
adds.append(("links", "TEXT")) |
|
|
adds.append(("links", "TEXT")) |
|
|
if "notes" not in cols: |
|
|
if "notes" not in cols: |
|
|
@ -224,8 +247,8 @@ class TaskDB: |
|
|
now = datetime.now(timezone.utc).isoformat() |
|
|
now = datetime.now(timezone.utc).isoformat() |
|
|
cur = self.conn.cursor() |
|
|
cur = self.conn.cursor() |
|
|
cur.execute(""" |
|
|
cur.execute(""" |
|
|
INSERT INTO tasks (title, brief, description, priority, type, status, start_date, links, notes, updated_at) |
|
|
INSERT INTO tasks (title, brief, description, priority, type, status, start_date, deadline, links, notes, updated_at) |
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
|
|
""", ( |
|
|
""", ( |
|
|
task.get("title", ""), |
|
|
task.get("title", ""), |
|
|
task.get("brief", ""), |
|
|
task.get("brief", ""), |
|
|
@ -234,6 +257,7 @@ class TaskDB: |
|
|
task.get("type"), |
|
|
task.get("type"), |
|
|
task.get("status"), |
|
|
task.get("status"), |
|
|
task.get("start_date"), |
|
|
task.get("start_date"), |
|
|
|
|
|
task.get("deadline"), |
|
|
json.dumps(task.get("links", []), ensure_ascii=False), |
|
|
json.dumps(task.get("links", []), ensure_ascii=False), |
|
|
task.get("notes", ""), |
|
|
task.get("notes", ""), |
|
|
now |
|
|
now |
|
|
@ -244,7 +268,7 @@ class TaskDB: |
|
|
def update_task(self, tid: int, task: dict): |
|
|
def update_task(self, tid: int, task: dict): |
|
|
now = datetime.now(timezone.utc).isoformat() |
|
|
now = datetime.now(timezone.utc).isoformat() |
|
|
self.conn.execute(""" |
|
|
self.conn.execute(""" |
|
|
UPDATE tasks SET title=?, brief=?, description=?, priority=?, type=?, status=?, start_date=?, links=?, notes=?, updated_at=? |
|
|
UPDATE tasks SET title=?, brief=?, description=?, priority=?, type=?, status=?, start_date=?, deadline=?, links=?, notes=?, updated_at=? |
|
|
WHERE sid=? |
|
|
WHERE sid=? |
|
|
""", ( |
|
|
""", ( |
|
|
task.get("title", ""), |
|
|
task.get("title", ""), |
|
|
@ -254,6 +278,7 @@ class TaskDB: |
|
|
task.get("type"), |
|
|
task.get("type"), |
|
|
task.get("status"), |
|
|
task.get("status"), |
|
|
task.get("start_date"), |
|
|
task.get("start_date"), |
|
|
|
|
|
task.get("deadline"), |
|
|
json.dumps(task.get("links", []), ensure_ascii=False), |
|
|
json.dumps(task.get("links", []), ensure_ascii=False), |
|
|
task.get("notes", ""), |
|
|
task.get("notes", ""), |
|
|
now, |
|
|
now, |
|
|
@ -363,24 +388,43 @@ def compute_composite_score(task_row: dict) -> float: |
|
|
Compute composite priority score using COMPOSITE_WEIGHTS. |
|
|
Compute composite priority score using COMPOSITE_WEIGHTS. |
|
|
Higher number => more urgent. |
|
|
Higher number => more urgent. |
|
|
""" |
|
|
""" |
|
|
# 读取数据 |
|
|
# 任务开始以来的天数 |
|
|
start_date = task_row.get("start_date") |
|
|
start_date = task_row.get("start_date") |
|
|
days = 0 |
|
|
days_from_start = 0 |
|
|
try: |
|
|
try: |
|
|
if start_date: |
|
|
if start_date: |
|
|
dt = datetime.fromisoformat(start_date) |
|
|
dt = datetime.fromisoformat(start_date) |
|
|
# convert to local naive days |
|
|
# convert to local naive days |
|
|
days = (datetime.now(dt.tzinfo or timezone.utc).date() - dt.date()).days |
|
|
days_from_start = (datetime.now(dt.tzinfo or timezone.utc).date() - dt.date()).days |
|
|
if days < 0: |
|
|
if days_from_start < 0: |
|
|
days = 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: |
|
|
except Exception: |
|
|
days = 0 |
|
|
have_deadline = False |
|
|
d_score = COMPOSITE_WEIGHTS.get("age_factor", 0.1) * days + 1 |
|
|
# 各种因素分数 |
|
|
|
|
|
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) |
|
|
p_score = COMPOSITE_WEIGHTS["priority"].get(task_row.get("priority"), 1.0) |
|
|
t_score = COMPOSITE_WEIGHTS["type"].get(task_row.get("type"), 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) |
|
|
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 |
|
|
score = p_score * t_score * s_score * d_score * ddl_score |
|
|
return round(float(score), 6) |
|
|
return round(float(score), 6) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -401,6 +445,7 @@ class TaskEditor(ctk.CTkToplevel): |
|
|
self.title_var = ctk.StringVar() |
|
|
self.title_var = ctk.StringVar() |
|
|
self.brief_var = ctk.StringVar() |
|
|
self.brief_var = ctk.StringVar() |
|
|
self.start_var = ctk.StringVar(value=date.today().isoformat()) |
|
|
self.start_var = ctk.StringVar(value=date.today().isoformat()) |
|
|
|
|
|
self.deadline_var = ctk.StringVar(value="") |
|
|
self.priority_var = ctk.StringVar() |
|
|
self.priority_var = ctk.StringVar() |
|
|
self.type_var = ctk.StringVar() |
|
|
self.type_var = ctk.StringVar() |
|
|
self.status_var = ctk.StringVar() |
|
|
self.status_var = ctk.StringVar() |
|
|
@ -446,33 +491,37 @@ class TaskEditor(ctk.CTkToplevel): |
|
|
ctk.CTkLabel(frm, text="Start Date (YYYY-MM-DD)").grid(row=8, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0)) |
|
|
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) |
|
|
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 |
|
|
# links |
|
|
ctk.CTkLabel(frm, text="Links (one per line)").grid(row=10, column=0, columnspan=3, sticky="w", padx=pad, pady=(10,0)) |
|
|
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 = ctk.CTkTextbox(frm, height=80) |
|
|
self.links_text.grid(row=11, column=0, columnspan=3, sticky="nsew", padx=pad) |
|
|
self.links_text.grid(row=13, column=0, columnspan=3, sticky="nsew", padx=pad) |
|
|
|
|
|
|
|
|
# logs (processed dates) |
|
|
# 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)) |
|
|
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 = ctk.CTkFrame(frm) |
|
|
logs_frame.grid(row=13, column=0, columnspan=3, sticky="ew", padx=pad, pady=(4,0)) |
|
|
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="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="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) |
|
|
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) |
|
|
# list widget for logs (tk Listbox embedded) |
|
|
self.logs_listbox = tk.Listbox(frm, height=5) |
|
|
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)) |
|
|
self.logs_listbox.grid(row=16, column=0, columnspan=3, sticky="nsew", padx=pad, pady=(4,0)) |
|
|
|
|
|
|
|
|
# buttons |
|
|
# buttons |
|
|
btn_row = ctk.CTkFrame(frm) |
|
|
btn_row = ctk.CTkFrame(frm) |
|
|
btn_row.grid(row=15, column=0, columnspan=3, pady=(12,6)) |
|
|
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="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) |
|
|
ctk.CTkButton(btn_row, text="Cancel", fg_color="#888", hover_color="#666", command=self.destroy).pack(side="left", padx=8) |
|
|
|
|
|
|
|
|
# grid weights |
|
|
# grid weights |
|
|
frm.grid_rowconfigure(5, weight=1) |
|
|
frm.grid_rowconfigure(5, weight=1) |
|
|
frm.grid_rowconfigure(11, weight=0) |
|
|
frm.grid_rowconfigure(13, weight=0) |
|
|
frm.grid_rowconfigure(14, weight=0) |
|
|
frm.grid_rowconfigure(16, weight=0) |
|
|
frm.grid_columnconfigure(0, weight=1) |
|
|
frm.grid_columnconfigure(0, weight=1) |
|
|
frm.grid_columnconfigure(1, weight=1) |
|
|
frm.grid_columnconfigure(1, weight=1) |
|
|
frm.grid_columnconfigure(2, weight=1) |
|
|
frm.grid_columnconfigure(2, weight=1) |
|
|
@ -489,6 +538,7 @@ class TaskEditor(ctk.CTkToplevel): |
|
|
self.type_cb.set(row.get("type") or "") |
|
|
self.type_cb.set(row.get("type") or "") |
|
|
self.status_cb.set(row.get("status") or "") |
|
|
self.status_cb.set(row.get("status") or "") |
|
|
self.start_var.set(row.get("start_date") 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 "[]") |
|
|
links = json.loads(row.get("links") or "[]") |
|
|
self.links_text.delete("1.0", "end"); self.links_text.insert("1.0", "\n".join(links)) |
|
|
self.links_text.delete("1.0", "end"); self.links_text.insert("1.0", "\n".join(links)) |
|
|
# logs |
|
|
# logs |
|
|
@ -556,6 +606,7 @@ class TaskEditor(ctk.CTkToplevel): |
|
|
"type": self.type_cb.get() or None, |
|
|
"type": self.type_cb.get() or None, |
|
|
"status": self.status_cb.get() or None, |
|
|
"status": self.status_cb.get() or None, |
|
|
"start_date": self.start_var.get().strip() or date.today().isoformat(), |
|
|
"start_date": self.start_var.get().strip() or date.today().isoformat(), |
|
|
|
|
|
"deadline": self.deadline_var.get().strip() or "", |
|
|
"links": links, |
|
|
"links": links, |
|
|
"processed_dates": getattr(self, "_logs", []) |
|
|
"processed_dates": getattr(self, "_logs", []) |
|
|
} |
|
|
} |
|
|
@ -575,6 +626,7 @@ class TaskEditor(ctk.CTkToplevel): |
|
|
"type": data["type"], |
|
|
"type": data["type"], |
|
|
"status": data["status"], |
|
|
"status": data["status"], |
|
|
"start_date": data["start_date"], |
|
|
"start_date": data["start_date"], |
|
|
|
|
|
"deadline": data["deadline"], |
|
|
"links": data["links"], |
|
|
"links": data["links"], |
|
|
"notes": data["notes"] |
|
|
"notes": data["notes"] |
|
|
}) |
|
|
}) |
|
|
@ -595,6 +647,7 @@ class TaskEditor(ctk.CTkToplevel): |
|
|
"type": data["type"], |
|
|
"type": data["type"], |
|
|
"status": data["status"], |
|
|
"status": data["status"], |
|
|
"start_date": data["start_date"], |
|
|
"start_date": data["start_date"], |
|
|
|
|
|
"deadline": data["deadline"], |
|
|
"links": data["links"], |
|
|
"links": data["links"], |
|
|
"notes": data["notes"], |
|
|
"notes": data["notes"], |
|
|
}) |
|
|
}) |
|
|
@ -628,6 +681,10 @@ class TaskManagerApp(ctk.CTk): |
|
|
# guard to prevent duplicate toggles from multiple bound handlers |
|
|
# guard to prevent duplicate toggles from multiple bound handlers |
|
|
self._last_header_toggle = None |
|
|
self._last_header_toggle = None |
|
|
|
|
|
|
|
|
|
|
|
# 调整单元格宽度所需参数 |
|
|
|
|
|
self._prev_col_widths = {} |
|
|
|
|
|
self._resized_column = None |
|
|
|
|
|
|
|
|
# build UI |
|
|
# build UI |
|
|
self._build_ui() |
|
|
self._build_ui() |
|
|
self.refresh_table() |
|
|
self.refresh_table() |
|
|
@ -702,30 +759,35 @@ class TaskManagerApp(ctk.CTk): |
|
|
self.sheet.grid(row=0, column=0, sticky="nsew") |
|
|
self.sheet.grid(row=0, column=0, sticky="nsew") |
|
|
table_frame.grid_rowconfigure(0, weight=1); table_frame.grid_columnconfigure(0, weight=1) |
|
|
table_frame.grid_rowconfigure(0, weight=1); table_frame.grid_columnconfigure(0, weight=1) |
|
|
|
|
|
|
|
|
# bind double click using extra_bindings if available |
|
|
# =============================== |
|
|
|
|
|
# 各种绑定 |
|
|
try: |
|
|
try: |
|
|
self.sheet.extra_bindings([("double_click_cell", self._on_double_click_cell)]) |
|
|
self.sheet.extra_bindings([("double_click_cell", self._on_double_click_cell)]) |
|
|
except Exception: |
|
|
except Exception: |
|
|
self.sheet.bind("<Double-1>", self._on_double_click_generic, add="+") |
|
|
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: |
|
|
try: |
|
|
self.sheet.extra_bindings([("column_select", self._on_header_click)]) |
|
|
self.sheet.extra_bindings([("column_select", self._on_header_click)]) |
|
|
except Exception: |
|
|
except Exception as e: |
|
|
print('[Error] bind _on_header_click failed!!!') |
|
|
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="+") |
|
|
|
|
|
|
|
|
# header/column clicks - generic |
|
|
|
|
|
# primary binding directly on sheet |
|
|
|
|
|
self.sheet.bind("<Button-1>", self._on_sheet_click_generic, add="+") |
|
|
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="+") |
|
|
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("<Button-1>", self._on_root_click, add="+") |
|
|
self.bind_all("<ButtonRelease-1>", self._on_root_click, add="+") |
|
|
self.bind_all("<ButtonRelease-1>", self._on_root_click, add="+") |
|
|
|
|
|
|
|
|
# 指定列居中 |
|
|
# 指定列居中 |
|
|
align_center_cols = [] |
|
|
align_center_cols = [] |
|
|
for i, (key, _) in enumerate(COLUMNS): |
|
|
for i, (key, _) in enumerate(COLUMNS): |
|
|
if key in ('sid', 'priority', 'type', 'status', 'start_date', 'processed_today', 'last_processed', 'links_count', 'composite'): |
|
|
if key in ('sid', 'priority', 'type', 'status', 'start_date', 'processed_today', 'last_processed', 'links_count', 'composite', 'deadline'): |
|
|
align_center_cols.append(i) |
|
|
align_center_cols.append(i) |
|
|
self.sheet.align_columns(columns=align_center_cols, align="center") |
|
|
self.sheet.align_columns(columns=align_center_cols, align="center") |
|
|
|
|
|
|
|
|
@ -758,6 +820,20 @@ class TaskManagerApp(ctk.CTk): |
|
|
elif col == "processed_today": |
|
|
elif col == "processed_today": |
|
|
today_iso = date.today().isoformat() |
|
|
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) |
|
|
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: |
|
|
else: |
|
|
rows.sort(key=lambda r: r.get(col) or "", reverse=not self.order_asc) |
|
|
rows.sort(key=lambda r: r.get(col) or "", reverse=not self.order_asc) |
|
|
|
|
|
|
|
|
@ -801,6 +877,8 @@ class TaskManagerApp(ctk.CTk): |
|
|
row.append(r.get("last_processed") or "") |
|
|
row.append(r.get("last_processed") or "") |
|
|
elif key == "start_date": |
|
|
elif key == "start_date": |
|
|
row.append(r.get("start_date") or "") |
|
|
row.append(r.get("start_date") or "") |
|
|
|
|
|
elif key == "deadline": |
|
|
|
|
|
row.append(r.get("deadline") or "") |
|
|
elif key == "links_count": |
|
|
elif key == "links_count": |
|
|
row.append(str(links_count)) |
|
|
row.append(str(links_count)) |
|
|
elif key == "notes": |
|
|
elif key == "notes": |
|
|
@ -857,6 +935,7 @@ class TaskManagerApp(ctk.CTk): |
|
|
for r_idx, tid in enumerate(self.displayed_sids): |
|
|
for r_idx, tid in enumerate(self.displayed_sids): |
|
|
task = self.db.get_task(tid) |
|
|
task = self.db.get_task(tid) |
|
|
pr = task.get("priority"); ty = task.get("type"); st = task.get("status") |
|
|
pr = task.get("priority"); ty = task.get("type"); st = task.get("status") |
|
|
|
|
|
ddl = task.get("deadline") |
|
|
# check if processed today using processed_map |
|
|
# check if processed today using processed_map |
|
|
processed = processed_map.get(tid, False) |
|
|
processed = processed_map.get(tid, False) |
|
|
if pr and pr in PRIORITY_DISPLAY: |
|
|
if pr and pr in PRIORITY_DISPLAY: |
|
|
@ -877,6 +956,27 @@ class TaskManagerApp(ctk.CTk): |
|
|
except Exception: |
|
|
except Exception: |
|
|
try: self.sheet.set_cell_bg(r_idx,COL_STATUS,bg) |
|
|
try: self.sheet.set_cell_bg(r_idx,COL_STATUS,bg) |
|
|
except Exception: pass |
|
|
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 |
|
|
# processed today column: use COL_PROCESSED_TODAY constant |
|
|
if processed: |
|
|
if processed: |
|
|
bg = PROCESSED_DISPLAY["yes"]["bg"] |
|
|
bg = PROCESSED_DISPLAY["yes"]["bg"] |
|
|
@ -889,10 +989,14 @@ class TaskManagerApp(ctk.CTk): |
|
|
|
|
|
|
|
|
# try auto row size |
|
|
# try auto row size |
|
|
try: |
|
|
try: |
|
|
|
|
|
# self.sheet.refresh() |
|
|
self.sheet.set_all_cell_sizes_to_text() |
|
|
self.sheet.set_all_cell_sizes_to_text() |
|
|
except Exception: |
|
|
except Exception: |
|
|
pass |
|
|
pass |
|
|
|
|
|
|
|
|
|
|
|
# reset column width cache |
|
|
|
|
|
self._init_column_width_cache() |
|
|
|
|
|
|
|
|
def _clear_filters(self): |
|
|
def _clear_filters(self): |
|
|
self.search_var.set("") |
|
|
self.search_var.set("") |
|
|
self.type_listbox.selection_clear(0, "end") |
|
|
self.type_listbox.selection_clear(0, "end") |
|
|
@ -955,8 +1059,56 @@ class TaskManagerApp(ctk.CTk): |
|
|
tid = self.displayed_sids[r] |
|
|
tid = self.displayed_sids[r] |
|
|
row = self.db.get_task(tid) |
|
|
row = self.db.get_task(tid) |
|
|
links = json.loads(row.get("links") or "[]") |
|
|
links = json.loads(row.get("links") or "[]") |
|
|
if not links: messagebox.showinfo("No links", "No links found"); return |
|
|
if not links: |
|
|
for u in links: webbrowser.open(u) |
|
|
messagebox.showinfo("No links", "No links found") |
|
|
|
|
|
return |
|
|
|
|
|
# Single link: open immediately (preserve original behaviour) |
|
|
|
|
|
if len(links) == 1: |
|
|
|
|
|
try: |
|
|
|
|
|
webbrowser.open(links[0]) |
|
|
|
|
|
except Exception: |
|
|
|
|
|
messagebox.showerror("Error", "Failed to open link") |
|
|
|
|
|
return |
|
|
|
|
|
|
|
|
|
|
|
# Multiple links: show a small window listing them; user selects one to open |
|
|
|
|
|
win = ctk.CTkToplevel(self) |
|
|
|
|
|
win.title("Open Link") |
|
|
|
|
|
win.geometry("640x320") |
|
|
|
|
|
# header |
|
|
|
|
|
try: |
|
|
|
|
|
ctk.CTkLabel(win, text=f"Links for task {tid}").pack(anchor="w", padx=8, pady=(8,0)) |
|
|
|
|
|
except Exception: |
|
|
|
|
|
tk.Label(win, text=f"Links for task {tid}").pack(anchor="w", padx=8, pady=(8,0)) |
|
|
|
|
|
|
|
|
|
|
|
lb = tk.Listbox(win, height=10) |
|
|
|
|
|
lb.pack(fill="both", expand=True, padx=8, pady=8) |
|
|
|
|
|
for u in links: |
|
|
|
|
|
lb.insert("end", u) |
|
|
|
|
|
|
|
|
|
|
|
def _open_selected(): |
|
|
|
|
|
sel = lb.curselection() |
|
|
|
|
|
if not sel: |
|
|
|
|
|
messagebox.showinfo("Info", "Select a link") |
|
|
|
|
|
return |
|
|
|
|
|
url = lb.get(sel[0]) |
|
|
|
|
|
try: |
|
|
|
|
|
webbrowser.open(url) |
|
|
|
|
|
except Exception: |
|
|
|
|
|
messagebox.showerror("Error", "Failed to open link") |
|
|
|
|
|
win.destroy() |
|
|
|
|
|
|
|
|
|
|
|
def _open_all(): |
|
|
|
|
|
for u in links: |
|
|
|
|
|
try: webbrowser.open(u) |
|
|
|
|
|
except Exception: pass |
|
|
|
|
|
win.destroy() |
|
|
|
|
|
|
|
|
|
|
|
lb.bind("<Double-1>", lambda e: _open_selected()) |
|
|
|
|
|
|
|
|
|
|
|
btn_row = ctk.CTkFrame(win) |
|
|
|
|
|
btn_row.pack(fill="x", padx=8, pady=8) |
|
|
|
|
|
ctk.CTkButton(btn_row, text="Open All", command=_open_all).pack(side="left", padx=6) |
|
|
|
|
|
ctk.CTkButton(btn_row, text="Close", fg_color="#888", hover_color="#666", command=win.destroy).pack(side="right", padx=6) |
|
|
|
|
|
|
|
|
def mark_selected_processed_today(self): |
|
|
def mark_selected_processed_today(self): |
|
|
r = self._get_first_selected_row_index() |
|
|
r = self._get_first_selected_row_index() |
|
|
@ -1018,6 +1170,24 @@ class TaskManagerApp(ctk.CTk): |
|
|
# swallow - no-op |
|
|
# swallow - no-op |
|
|
pass |
|
|
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 |
|
|
# header click generic for sorting |
|
|
def _on_sheet_click_generic(self, event): |
|
|
def _on_sheet_click_generic(self, event): |
|
|
try: |
|
|
try: |
|
|
@ -1410,6 +1580,59 @@ class TaskManagerApp(ctk.CTk): |
|
|
ctk.CTkButton(right, text="Open Selected Task", command=open_task).pack(pady=6) |
|
|
ctk.CTkButton(right, text="Open Selected Task", command=open_task).pack(pady=6) |
|
|
on_select() |
|
|
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 |
|
|
# run / refresh |
|
|
def run(self): |
|
|
def run(self): |
|
|
self.mainloop() |
|
|
self.mainloop() |
|
|
|